openship 0.6.9 → 0.7.1

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 (62) hide show
  1. package/dist/bare-AB4SMTNJ.js +29 -0
  2. package/dist/{chunk-P7RCYELG.js → chunk-2BAKEGGF.js} +1 -1
  3. package/dist/{chunk-VAI5VOOI.js → chunk-2YAY5XHK.js} +1 -1
  4. package/dist/{chunk-X53JXRS4.js → chunk-37XZTLBJ.js} +3 -3
  5. package/dist/{chunk-R5DC5PEF.js → chunk-3K65MMYV.js} +39 -10
  6. package/dist/{chunk-D3OEMXYP.js → chunk-3WBKLDGC.js} +12 -7
  7. package/dist/{chunk-ENKJ7XIF.js → chunk-3YSSXSC2.js} +12 -12
  8. package/dist/{chunk-3MP6ACKM.js → chunk-7MCPDYKP.js} +799 -165
  9. package/dist/{chunk-D6HU4VUZ.js → chunk-7TQP6723.js} +2 -2
  10. package/dist/{chunk-SK4FFCFR.js → chunk-AMADTKC6.js} +1 -1
  11. package/dist/{chunk-NPSRGIHV.js → chunk-B2BQX6JX.js} +6 -5
  12. package/dist/{chunk-MUWVC5R4.js → chunk-BBPKAQ54.js} +12 -9
  13. package/dist/{chunk-WEZFTL4V.js → chunk-COI4VFRW.js} +217 -2
  14. package/dist/{chunk-5DJOB7IX.js → chunk-CQ2JOXEQ.js} +3 -3
  15. package/dist/{chunk-7PEXM2UI.js → chunk-DQR5TPFB.js} +6 -6
  16. package/dist/{chunk-IAMOUDHY.js → chunk-ETK6IBCH.js} +24 -15
  17. package/dist/{chunk-T46BQOLY.js → chunk-FNWNUYS3.js} +3 -3
  18. package/dist/{chunk-UAWY6X7V.js → chunk-GPTBEPUE.js} +485 -67
  19. package/dist/{chunk-634DDFT5.js → chunk-H36ASAYI.js} +8 -8
  20. package/dist/{chunk-AHJYP7DG.js → chunk-IVMPA6RC.js} +74 -17
  21. package/dist/{chunk-5UXHT6ED.js → chunk-IXBOV7BE.js} +113 -20
  22. package/dist/{chunk-GNOMEMNI.js → chunk-KATXZ5AB.js} +1 -1
  23. package/dist/{chunk-T6DVUX4F.js → chunk-KCMEWNP7.js} +14 -3
  24. package/dist/{chunk-N5YZMO33.js → chunk-L4DRH6ZA.js} +12 -1
  25. package/dist/{chunk-BO6CY4V2.js → chunk-NF7NQOAP.js} +47 -4
  26. package/dist/{chunk-FCGJN5Q5.js → chunk-NIOLZENU.js} +9 -9
  27. package/dist/{chunk-7YBWXEM6.js → chunk-OEYXXSAM.js} +4 -4
  28. package/dist/{chunk-6GU6JSK6.js → chunk-S5GU345P.js} +3 -3
  29. package/dist/{chunk-TAZZZKYO.js → chunk-TKK5NPDQ.js} +2 -2
  30. package/dist/{chunk-NUT6HKK6.js → chunk-WG5PDWZ3.js} +5 -5
  31. package/dist/{chunk-NIYD7MD2.js → chunk-XJSLJLNY.js} +90 -46
  32. package/dist/{cloud-REV3DPJH.js → cloud-T2JOFEQL.js} +20 -20
  33. package/dist/{detect-BBFT5GEU.js → detect-SWLZURYX.js} +5 -5
  34. package/dist/{docker-EG3QSE6I.js → docker-SQNZRZXN.js} +11 -11
  35. package/dist/{docker-edge-executor-E5TOTLMR.js → docker-edge-executor-BSSQQS76.js} +8 -8
  36. package/dist/{edge-import-YJVJUGYC.js → edge-import-LUDX77C2.js} +27 -27
  37. package/dist/{edge-preflight-QUW4BM7N.js → edge-preflight-JAEFWBRI.js} +14 -14
  38. package/dist/{ensure-container-edge-FOEFQ3PI.js → ensure-container-edge-U7FTLFVJ.js} +12 -12
  39. package/dist/{executor-NTWDJ3DB.js → executor-GYRZ7RIU.js} +7 -7
  40. package/dist/host-channel-preflight-U4IZCRXR.js +54 -0
  41. package/dist/index.js +72 -50
  42. package/dist/{local-executor-OQ2GRN7K.js → local-executor-JCR46VAP.js} +3 -3
  43. package/dist/{nginx-4U4C2XUS.js → nginx-YA6IRPJT.js} +10 -10
  44. package/dist/{openresty-lua-LLWT5XQW.js → openresty-lua-DUOB64TP.js} +8 -8
  45. package/dist/{privilege-MNDVMDST.js → privilege-4B2TMVW2.js} +6 -6
  46. package/dist/server/index.js +13286 -5381
  47. package/dist/server/lua/site_logger.lua +18 -1
  48. package/dist/server/migrations/0117_github_installation_workspace_scope.sql +24 -0
  49. package/dist/server/migrations/0118_git_sources.sql +66 -0
  50. package/dist/server/migrations/0119_backup_run_batch.sql +8 -0
  51. package/dist/server/migrations/0120_domain_primary_unique.sql +24 -0
  52. package/dist/server/migrations/0121_host_port_claim_overlap.sql +14 -0
  53. package/dist/server/migrations/0122_data_transfer_staging.sql +45 -0
  54. package/dist/server/migrations/meta/_journal.json +42 -0
  55. package/dist/setup-T3WN3HRS.js +25 -0
  56. package/dist/{ssh-client-2XWZCMGI.js → ssh-client-FZDWP5PQ.js} +3 -3
  57. package/dist/up-U73RUN5R.js +61 -0
  58. package/package.json +1 -1
  59. package/dist/bare-FA66N7GC.js +0 -29
  60. package/dist/host-channel-preflight-OI2TRHSG.js +0 -54
  61. package/dist/setup-IFIMPQZJ.js +0 -25
  62. package/dist/up-RC5ZQIAD.js +0 -61
@@ -8,30 +8,32 @@ import {
8
8
  import {
9
9
  getTarCreateEnv,
10
10
  prepareSourceTarArgs
11
- } from "./chunk-VAI5VOOI.js";
11
+ } from "./chunk-2YAY5XHK.js";
12
12
  import {
13
13
  connectSshClient,
14
14
  openDockerDialStdioChannel,
15
15
  openSftp,
16
16
  openSshUnixSocket,
17
17
  reconcileKnownHosts
18
- } from "./chunk-TAZZZKYO.js";
18
+ } from "./chunk-TKK5NPDQ.js";
19
19
  import {
20
20
  LocalExecutor
21
- } from "./chunk-T6DVUX4F.js";
21
+ } from "./chunk-KCMEWNP7.js";
22
22
  import {
23
23
  emitBufferedLines,
24
24
  flushBufferedLines,
25
25
  hasLocalCommand,
26
26
  logEntry,
27
27
  sq
28
- } from "./chunk-P7RCYELG.js";
28
+ } from "./chunk-2BAKEGGF.js";
29
29
  import {
30
30
  HostChannelUnavailableError,
31
31
  SshDisconnectedError,
32
+ SshExecRequestError,
32
33
  commandForError,
33
- isSshAuthError
34
- } from "./chunk-N5YZMO33.js";
34
+ isSshAuthError,
35
+ isSshExecRequestError
36
+ } from "./chunk-L4DRH6ZA.js";
35
37
  import {
36
38
  HOST_CHANNEL_AUTH_REJECTED,
37
39
  HOST_CHANNEL_NOT_PROVISIONED,
@@ -42,13 +44,14 @@ import {
42
44
  hostChannelAccount,
43
45
  hostFirewallRule,
44
46
  safeErrorMessage
45
- } from "./chunk-WEZFTL4V.js";
47
+ } from "./chunk-COI4VFRW.js";
46
48
 
47
49
  // ../../packages/adapters/src/system/executor.ts
48
50
  import { existsSync, readFileSync } from "fs";
49
51
  import { networkInterfaces } from "os";
50
52
 
51
53
  // ../../packages/adapters/src/system/ssh-executor.ts
54
+ import { AsyncLocalStorage } from "async_hooks";
52
55
  import { createReadStream } from "fs";
53
56
  import { mkdtemp as mkdtemp2, rm as fsRm2, stat } from "fs/promises";
54
57
  import { tmpdir as tmpdir2 } from "os";
@@ -250,6 +253,19 @@ async function uploadFileWithRsync(localFile, remoteFile, deps, onLog, opts) {
250
253
 
251
254
  // ../../packages/adapters/src/system/ssh-executor.ts
252
255
  var remoteDirname = posix.dirname;
256
+ var SFTP_OPERATION_TIMEOUT_MS = 3e4;
257
+ var CHANNEL_CLOSE_GRACE_MS = 750;
258
+ var TRANSPORT_CLOSE_GRACE_MS = 250;
259
+ function tagExecRequestError(error) {
260
+ return error.message === "Unable to exec" ? new SshExecRequestError(error) : error;
261
+ }
262
+ function abortError(operation, signal) {
263
+ const reason = signal.reason;
264
+ const suffix = reason instanceof Error && reason.message ? `: ${reason.message}` : "";
265
+ const error = new Error(`SSH ${operation} cancelled${suffix}`);
266
+ error.name = "AbortError";
267
+ return error;
268
+ }
253
269
  function clampWindow(value, fallback, min, max) {
254
270
  if (typeof value !== "number" || !Number.isFinite(value)) return fallback;
255
271
  return Math.max(min, Math.min(max, Math.floor(value)));
@@ -259,10 +275,19 @@ var SshExecutor = class _SshExecutor {
259
275
  connecting = null;
260
276
  /** One shared SFTP subsystem channel per client — see sftp(). */
261
277
  sftpChannel = null;
278
+ sftpWrapper = null;
279
+ /** All requests cancelled on one shared channel join the same close/reset
280
+ * barrier, so the second listener cannot miss the first listener's `close`
281
+ * event and needlessly wait for the force-reset deadline. */
282
+ sftpQuiescence = /* @__PURE__ */ new WeakMap();
262
283
  config;
284
+ /** Deployment cancellation belongs to an async call tree, not to this pooled
285
+ * executor instance. AsyncLocalStorage prevents one deployment from making
286
+ * unrelated users of the same SSH connection inherit its signal. */
287
+ abortScope = new AsyncLocalStorage();
263
288
  /** Subscribers notified when the transport drops (see onDisconnect). */
264
289
  disconnectListeners = /* @__PURE__ */ new Set();
265
- /** In-flight cancelable ops — each entry aborts ONE exec/stream on a drop,
290
+ /** In-flight cancelable ops — each entry aborts ONE exec/stream/SFTP request on a drop,
266
291
  * so a dead channel fails fast instead of hanging to its command timeout. */
267
292
  inflight = /* @__PURE__ */ new Set();
268
293
  /** Reverse-forward handlers keyed by the remote bound port (see reverseForward). */
@@ -275,15 +300,66 @@ var SshExecutor = class _SshExecutor {
275
300
  }
276
301
  this.config = config;
277
302
  }
303
+ runWithAbortSignal(signal, fn) {
304
+ if (signal.aborted) return Promise.reject(abortError("operation", signal));
305
+ return this.abortScope.run(signal, async () => {
306
+ this.throwIfAborted("operation");
307
+ return fn();
308
+ });
309
+ }
310
+ operationSignal() {
311
+ return this.abortScope.getStore();
312
+ }
313
+ throwIfAborted(operation, signal = this.operationSignal()) {
314
+ if (signal?.aborted) throw abortError(operation, signal);
315
+ }
316
+ resolvedSignal(explicit) {
317
+ const ambient = this.operationSignal();
318
+ if (!explicit || explicit === ambient) return ambient ?? explicit;
319
+ if (!ambient) return explicit;
320
+ return AbortSignal.any([explicit, ambient]);
321
+ }
322
+ /** A handshake is already bounded by ssh2's readyTimeout. A deployment
323
+ * cancellation may stop waiting sooner because no remote mutation can have
324
+ * started before connect() resolves; the shared attempt is left alive for an
325
+ * unrelated/following caller instead of tearing down pooled state. */
326
+ awaitConnection(attempt) {
327
+ const signal = this.operationSignal();
328
+ if (!signal) return attempt;
329
+ this.throwIfAborted("connection", signal);
330
+ return new Promise((resolve, reject) => {
331
+ let settled = false;
332
+ const finish = (act) => {
333
+ if (settled) return;
334
+ settled = true;
335
+ signal.removeEventListener("abort", onAbort);
336
+ act();
337
+ };
338
+ const onAbort = () => finish(() => reject(abortError("connection", signal)));
339
+ signal.addEventListener("abort", onAbort, { once: true });
340
+ if (signal.aborted) {
341
+ onAbort();
342
+ return;
343
+ }
344
+ attempt.then(
345
+ (client) => finish(() => resolve(client)),
346
+ (error) => finish(() => reject(error))
347
+ );
348
+ });
349
+ }
278
350
  async connect() {
279
- if (this.client) return this.client;
280
- if (this.connecting) return this.connecting;
351
+ if (this.client) {
352
+ this.throwIfAborted("connection");
353
+ return this.client;
354
+ }
355
+ if (this.connecting) return this.awaitConnection(this.connecting);
281
356
  const attempt = (async () => {
282
357
  const client = await connectSshClient(this.config);
283
358
  const onTransportDown = (cause) => {
284
359
  if (this.client !== client) return;
285
360
  this.client = null;
286
361
  this.sftpChannel = null;
362
+ this.sftpWrapper = null;
287
363
  this.handleDisconnect(cause);
288
364
  };
289
365
  client.on("close", () => onTransportDown());
@@ -297,7 +373,7 @@ var SshExecutor = class _SshExecutor {
297
373
  if (this.connecting === attempt) this.connecting = null;
298
374
  };
299
375
  attempt.then(clearMemo, clearMemo);
300
- return attempt;
376
+ return this.awaitConnection(attempt);
301
377
  }
302
378
  /**
303
379
  * Subscribe to transport-level disconnects. Returns an unsubscribe fn.
@@ -318,6 +394,15 @@ var SshExecutor = class _SshExecutor {
318
394
  const err = new SshDisconnectedError(
319
395
  cause?.message ? `SSH connection lost: ${cause.message}` : "SSH connection lost"
320
396
  );
397
+ this.rejectInflight(err);
398
+ for (const cb of [...this.disconnectListeners]) {
399
+ try {
400
+ cb(err);
401
+ } catch {
402
+ }
403
+ }
404
+ }
405
+ rejectInflight(err) {
321
406
  const aborts = [...this.inflight];
322
407
  this.inflight.clear();
323
408
  for (const abort of aborts) {
@@ -326,12 +411,136 @@ var SshExecutor = class _SshExecutor {
326
411
  } catch {
327
412
  }
328
413
  }
329
- for (const cb of [...this.disconnectListeners]) {
414
+ }
415
+ async waitForChannelClose(channel, close) {
416
+ return new Promise((resolve) => {
417
+ let settled = false;
418
+ const finish = (closed) => {
419
+ if (settled) return;
420
+ settled = true;
421
+ clearTimeout(timer);
422
+ channel.removeListener("close", onClose);
423
+ channel.removeListener("error", onError);
424
+ resolve(closed);
425
+ };
426
+ const onClose = () => finish(true);
427
+ const onError = () => {
428
+ };
429
+ const timer = setTimeout(() => finish(false), CHANNEL_CLOSE_GRACE_MS);
430
+ timer.unref?.();
431
+ channel.once("close", onClose);
432
+ channel.once("error", onError);
330
433
  try {
331
- cb(err);
434
+ close();
332
435
  } catch {
436
+ finish(false);
333
437
  }
438
+ });
439
+ }
440
+ /** Hard transport reset used only when a channel cannot prove it closed.
441
+ * Every operation on that transport is rejected before we return, and a
442
+ * later operation must establish a fresh connection. */
443
+ async forceResetTransport(client, cause) {
444
+ if (this.client !== client) return;
445
+ this.client = null;
446
+ this.connecting = null;
447
+ this.sftpChannel = null;
448
+ this.sftpWrapper = null;
449
+ if (this.reverseListenerClient === client) this.reverseListenerClient = null;
450
+ this.rejectInflight(
451
+ new SshDisconnectedError(`SSH connection reset to quiesce an operation: ${cause.message}`)
452
+ );
453
+ let closed = false;
454
+ const closePromise = new Promise((resolve) => {
455
+ const done = () => {
456
+ if (closed) return;
457
+ closed = true;
458
+ resolve();
459
+ };
460
+ client.once("close", done);
461
+ client.once("end", done);
462
+ });
463
+ try {
464
+ client.end();
465
+ } catch {
334
466
  }
467
+ try {
468
+ client.destroy();
469
+ } catch {
470
+ }
471
+ await Promise.race([
472
+ closePromise,
473
+ new Promise((resolve) => {
474
+ const timer = setTimeout(resolve, TRANSPORT_CLOSE_GRACE_MS);
475
+ timer.unref?.();
476
+ })
477
+ ]);
478
+ }
479
+ quiesceSftp(client, wrapper, cause) {
480
+ if (wrapper) {
481
+ const existing = this.sftpQuiescence.get(wrapper);
482
+ if (existing) return existing;
483
+ }
484
+ const quiescence = this.quiesceSftpOnce(client, wrapper, cause);
485
+ if (wrapper) this.sftpQuiescence.set(wrapper, quiescence);
486
+ return quiescence;
487
+ }
488
+ async quiesceSftpOnce(client, wrapper, cause) {
489
+ if (!wrapper || this.sftpWrapper === wrapper) {
490
+ this.sftpChannel = null;
491
+ this.sftpWrapper = null;
492
+ }
493
+ if (wrapper) {
494
+ const closed = await this.waitForChannelClose(wrapper, () => wrapper.end());
495
+ if (closed) return;
496
+ }
497
+ await this.forceResetTransport(client, cause);
498
+ }
499
+ awaitSftpOpen(opening, client) {
500
+ const signal = this.operationSignal();
501
+ this.throwIfAborted("SFTP channel open", signal);
502
+ return new Promise((resolve, reject) => {
503
+ let settled = false;
504
+ let terminating = false;
505
+ const cleanup = () => {
506
+ clearTimeout(timer);
507
+ this.inflight.delete(abort);
508
+ signal?.removeEventListener("abort", onSignalAbort);
509
+ };
510
+ const finish = (act) => {
511
+ if (settled || terminating) return;
512
+ settled = true;
513
+ cleanup();
514
+ act();
515
+ };
516
+ const terminate = (error) => {
517
+ if (settled || terminating) return;
518
+ terminating = true;
519
+ cleanup();
520
+ const settle = () => {
521
+ settled = true;
522
+ reject(error);
523
+ };
524
+ void this.forceResetTransport(client, error).then(settle, settle);
525
+ };
526
+ const abort = (error) => terminate(error);
527
+ const onSignalAbort = () => terminate(abortError("SFTP channel open", signal));
528
+ const timer = setTimeout(
529
+ () => terminate(new Error(`SFTP channel open timed out after ${SFTP_OPERATION_TIMEOUT_MS}ms`)),
530
+ SFTP_OPERATION_TIMEOUT_MS
531
+ );
532
+ timer.unref?.();
533
+ this.inflight.add(abort);
534
+ signal?.addEventListener("abort", onSignalAbort, { once: true });
535
+ if (signal?.aborted) {
536
+ onSignalAbort();
537
+ return;
538
+ }
539
+ opening.then(
540
+ (wrapper) => finish(() => resolve(wrapper)),
541
+ (error) => finish(() => reject(error))
542
+ );
543
+ });
335
544
  }
336
545
  /**
337
546
  * One SFTP subsystem channel per client, opened lazily and shared by every
@@ -344,26 +553,39 @@ var SshExecutor = class _SshExecutor {
344
553
  */
345
554
  async sftp() {
346
555
  const client = await this.connect();
347
- if (this.sftpChannel) return this.sftpChannel;
556
+ this.throwIfAborted("SFTP channel open");
557
+ if (this.sftpChannel) return this.awaitSftpOpen(this.sftpChannel, client);
348
558
  const opening = openSftp(client).then((wrapper) => {
559
+ if (this.client !== client || this.sftpChannel !== opening) {
560
+ try {
561
+ wrapper.end();
562
+ } catch {
563
+ }
564
+ throw new SshDisconnectedError("SFTP channel opened after its SSH transport was reset");
565
+ }
349
566
  const drop = () => {
350
- if (this.sftpChannel === opening) this.sftpChannel = null;
567
+ if (this.sftpChannel === opening) {
568
+ this.sftpChannel = null;
569
+ this.sftpWrapper = null;
570
+ }
351
571
  };
352
572
  wrapper.once("close", drop);
353
573
  wrapper.once("error", drop);
574
+ this.sftpWrapper = wrapper;
354
575
  return wrapper;
355
576
  });
356
577
  opening.catch(() => {
357
578
  if (this.sftpChannel === opening) this.sftpChannel = null;
358
579
  });
359
580
  this.sftpChannel = opening;
360
- return opening;
581
+ return this.awaitSftpOpen(opening, client);
361
582
  }
362
583
  /** Close + forget the shared SFTP channel (frees its session) without
363
584
  * touching the SSH client, so file ops can reopen on the same connection. */
364
585
  dropSftp() {
365
586
  const ch = this.sftpChannel;
366
587
  this.sftpChannel = null;
588
+ this.sftpWrapper = null;
367
589
  if (ch) ch.then((w) => {
368
590
  try {
369
591
  w.end();
@@ -382,6 +604,10 @@ var SshExecutor = class _SshExecutor {
382
604
  this.client.end();
383
605
  } catch {
384
606
  }
607
+ try {
608
+ this.client.destroy();
609
+ } catch {
610
+ }
385
611
  this.client = null;
386
612
  }
387
613
  this.connecting = null;
@@ -398,25 +624,28 @@ var SshExecutor = class _SshExecutor {
398
624
  if (this.inflight.size > 0) this.dropSftp();
399
625
  else this.resetConnection();
400
626
  }
401
- /** Returns true if the error is an SSH channel-open failure. */
627
+ /** Returns true if the error is an SSH channel-open or channel-exec failure. */
402
628
  static isChannelError(err) {
403
629
  if (!(err instanceof Error)) return false;
630
+ if (isSshExecRequestError(err)) return true;
404
631
  const msg = err.message.toLowerCase();
405
632
  return msg.includes("channel open failure") || msg.includes("open failed");
406
633
  }
407
634
  /**
408
635
  * Run an operation, and if it fails opening an SSH channel on a half-dead
409
636
  * cached connection ("Channel open failure: open failed" — common after the
410
- * idle timeout drops the socket), drop the connection and retry ONCE on a
411
- * fresh one. This is why `exec` survives a stale connection; the SFTP-based
412
- * ops (writeFile/readFile/exists) must go through it too, or a deploy's route
637
+ * idle timeout drops the socket), recover channel capacity and retry ONCE.
638
+ * An idle connection is replaced; a busy one stays alive so its other command
639
+ * is not aborted (see recoverFromChannelError). The SFTP-based ops
640
+ * (writeFile/readFile/exists) must go through this too, or a deploy's route
413
641
  * write fails spuriously and only succeeds on a manual redeploy.
414
642
  */
415
643
  async withChannelRetry(fn) {
644
+ this.throwIfAborted("operation");
416
645
  try {
417
646
  return await fn();
418
647
  } catch (err) {
419
- if (_SshExecutor.isChannelError(err)) {
648
+ if (_SshExecutor.isChannelError(err) && !this.operationSignal()?.aborted) {
420
649
  this.recoverFromChannelError();
421
650
  return fn();
422
651
  }
@@ -431,26 +660,65 @@ var SshExecutor = class _SshExecutor {
431
660
  async _exec(command, opts) {
432
661
  const client = await this.connect();
433
662
  const timeout = opts?.timeout ?? 3e4;
663
+ const signal = this.operationSignal();
664
+ this.throwIfAborted("command", signal);
434
665
  return new Promise((resolve, reject) => {
435
666
  let settled = false;
667
+ let terminating = false;
436
668
  let timer = null;
437
- const abort = (err) => finish(() => reject(err));
438
- const finish = (act) => {
439
- if (settled) return;
440
- settled = true;
669
+ let channel = null;
670
+ const cleanup = () => {
441
671
  if (timer) clearTimeout(timer);
442
672
  this.inflight.delete(abort);
673
+ signal?.removeEventListener("abort", onSignalAbort);
674
+ };
675
+ const finish = (act) => {
676
+ if (settled || terminating) return;
677
+ settled = true;
678
+ cleanup();
443
679
  act();
444
680
  };
681
+ const terminate = (error) => {
682
+ if (settled || terminating) return;
683
+ terminating = true;
684
+ cleanup();
685
+ const quiesce = (async () => {
686
+ if (channel) {
687
+ const closed = await this.waitForChannelClose(channel, () => channel.close());
688
+ if (!closed) await this.forceResetTransport(client, error);
689
+ } else {
690
+ await this.forceResetTransport(client, error);
691
+ }
692
+ })();
693
+ const settle = () => {
694
+ settled = true;
695
+ reject(error);
696
+ };
697
+ void quiesce.then(settle, settle);
698
+ };
699
+ const abort = (err) => terminate(err);
700
+ const onSignalAbort = () => terminate(abortError("command", signal));
445
701
  this.inflight.add(abort);
702
+ signal?.addEventListener("abort", onSignalAbort, { once: true });
703
+ if (signal?.aborted) {
704
+ onSignalAbort();
705
+ return;
706
+ }
446
707
  timer = setTimeout(
447
- () => finish(
448
- () => reject(new Error(`Command timed out after ${timeout}ms: ${commandForError(command)}`))
449
- ),
708
+ () => terminate(new Error(`Command timed out after ${timeout}ms: ${commandForError(command)}`)),
450
709
  timeout
451
710
  );
711
+ timer.unref?.();
452
712
  client.exec(_SshExecutor.ENV_PREFIX + command, (err, stream) => {
453
- if (err) return finish(() => reject(err));
713
+ if (err) return finish(() => reject(tagExecRequestError(err)));
714
+ channel = stream;
715
+ if (terminating) {
716
+ try {
717
+ stream.close();
718
+ } catch {
719
+ }
720
+ return;
721
+ }
454
722
  let stdout = "";
455
723
  let stderr = "";
456
724
  stream.on("data", (data) => {
@@ -459,6 +727,7 @@ var SshExecutor = class _SshExecutor {
459
727
  stream.stderr.on("data", (data) => {
460
728
  stderr += data.toString();
461
729
  });
730
+ stream.on("error", (error) => finish(() => reject(error)));
462
731
  stream.on("close", (code) => {
463
732
  finish(() => {
464
733
  if (code !== 0) {
@@ -471,10 +740,11 @@ var SshExecutor = class _SshExecutor {
471
740
  });
472
741
  }
473
742
  streamExec(command, onLog, opts) {
474
- return this._streamExec(command, onLog, opts).catch((err) => {
475
- if (_SshExecutor.isChannelError(err)) {
743
+ const scopedOpts = { ...opts, signal: this.resolvedSignal(opts?.signal) };
744
+ return this._streamExec(command, onLog, scopedOpts).catch((err) => {
745
+ if (_SshExecutor.isChannelError(err) && !scopedOpts.signal?.aborted) {
476
746
  this.recoverFromChannelError();
477
- return this._streamExec(command, onLog, opts);
747
+ return this._streamExec(command, onLog, scopedOpts);
478
748
  }
479
749
  throw err;
480
750
  });
@@ -484,33 +754,56 @@ var SshExecutor = class _SshExecutor {
484
754
  const signal = opts?.signal;
485
755
  return new Promise((resolve, reject) => {
486
756
  let settled = false;
757
+ let terminating = false;
487
758
  const chunks = [];
488
- const abort = (err) => finish(() => reject(err));
489
- const finish = (act) => {
490
- if (settled) return;
491
- settled = true;
759
+ let channel = null;
760
+ const cleanup = () => {
492
761
  this.inflight.delete(abort);
493
762
  signal?.removeEventListener("abort", onSignalAbort);
763
+ };
764
+ const finish = (act) => {
765
+ if (settled || terminating) return;
766
+ settled = true;
767
+ cleanup();
494
768
  act();
495
769
  };
496
- this.inflight.add(abort);
497
- let channel = null;
498
- const onSignalAbort = () => {
499
- finish(() => resolve({ code: 0, output: chunks.join("") }));
500
- try {
501
- channel?.close();
502
- } catch {
503
- }
770
+ const terminate = (error, intentional) => {
771
+ if (settled || terminating) return;
772
+ terminating = true;
773
+ cleanup();
774
+ const quiesce = (async () => {
775
+ if (channel) {
776
+ const closed = await this.waitForChannelClose(channel, () => channel.close());
777
+ if (!closed) await this.forceResetTransport(client, error);
778
+ } else {
779
+ await this.forceResetTransport(client, error);
780
+ }
781
+ })();
782
+ const settle = () => {
783
+ settled = true;
784
+ if (intentional) resolve({ code: 0, output: chunks.join("") });
785
+ else reject(error);
786
+ };
787
+ void quiesce.then(settle, settle);
504
788
  };
789
+ const abort = (err) => terminate(err, false);
790
+ const onSignalAbort = () => terminate(abortError("streaming command", signal), true);
791
+ this.inflight.add(abort);
792
+ signal?.addEventListener("abort", onSignalAbort, { once: true });
505
793
  if (signal?.aborted) {
506
- finish(() => resolve({ code: 0, output: "" }));
794
+ onSignalAbort();
507
795
  return;
508
796
  }
509
797
  client.exec(_SshExecutor.ENV_PREFIX + command, (err, stream) => {
510
- if (err) return finish(() => reject(err));
798
+ if (err) return finish(() => reject(tagExecRequestError(err)));
511
799
  channel = stream;
512
- if (signal?.aborted) return onSignalAbort();
513
- signal?.addEventListener("abort", onSignalAbort, { once: true });
800
+ if (terminating || signal?.aborted) {
801
+ try {
802
+ stream.close();
803
+ } catch {
804
+ }
805
+ return;
806
+ }
514
807
  const onChunk = (data, level) => {
515
808
  const text = data.toString();
516
809
  if (!text) return;
@@ -519,6 +812,7 @@ var SshExecutor = class _SshExecutor {
519
812
  };
520
813
  stream.on("data", (data) => onChunk(data, "info"));
521
814
  stream.stderr.on("data", (data) => onChunk(data, "warn"));
815
+ stream.on("error", (error) => finish(() => reject(error)));
522
816
  let exitCode = null;
523
817
  stream.on("exit", (code) => {
524
818
  exitCode = code;
@@ -540,7 +834,62 @@ var SshExecutor = class _SshExecutor {
540
834
  });
541
835
  });
542
836
  }
543
- async writeFile(path, content) {
837
+ /** Turn ssh2's callback-only SFTP request into a bounded operation. A lost
838
+ * callback used to leave the compose deploy promise and its host mutex tail
839
+ * pending forever. Cancellation/timeout closes the shared subsystem first;
840
+ * if close itself wedges, the parent SSH transport is destroyed before this
841
+ * promise rejects. */
842
+ runSftpRequest(client, sftp, operation, start) {
843
+ const signal = this.operationSignal();
844
+ this.throwIfAborted(operation, signal);
845
+ return new Promise((resolve, reject) => {
846
+ let settled = false;
847
+ let terminating = false;
848
+ const cleanup = () => {
849
+ clearTimeout(timer);
850
+ this.inflight.delete(abort);
851
+ signal?.removeEventListener("abort", onSignalAbort);
852
+ };
853
+ const finish = (act) => {
854
+ if (settled || terminating) return;
855
+ settled = true;
856
+ cleanup();
857
+ act();
858
+ };
859
+ const terminate = (error) => {
860
+ if (settled || terminating) return;
861
+ terminating = true;
862
+ cleanup();
863
+ const settle = () => {
864
+ settled = true;
865
+ reject(error);
866
+ };
867
+ void this.quiesceSftp(client, sftp, error).then(settle, settle);
868
+ };
869
+ const abort = (error) => terminate(error);
870
+ const onSignalAbort = () => terminate(abortError(operation, signal));
871
+ const timer = setTimeout(
872
+ () => terminate(new Error(`${operation} timed out after ${SFTP_OPERATION_TIMEOUT_MS}ms`)),
873
+ SFTP_OPERATION_TIMEOUT_MS
874
+ );
875
+ timer.unref?.();
876
+ this.inflight.add(abort);
877
+ signal?.addEventListener("abort", onSignalAbort, { once: true });
878
+ if (signal?.aborted) {
879
+ onSignalAbort();
880
+ return;
881
+ }
882
+ try {
883
+ start((error, value) => {
884
+ if (error) finish(() => reject(error));
885
+ else finish(() => resolve(value));
886
+ });
887
+ } catch (error) {
888
+ finish(() => reject(error));
889
+ }
890
+ });
891
+ }
892
+ async writeFile(path, content, opts) {
544
893
  const dir = remoteDirname(path);
545
894
  try {
546
895
  await this.exec(`mkdir -p ${sq(dir)}`);
@@ -548,12 +897,21 @@ var SshExecutor = class _SshExecutor {
548
897
  }
549
898
  return this.withChannelRetry(async () => {
550
899
  const sftp = await this.sftp();
551
- return new Promise((resolve, reject) => {
552
- sftp.writeFile(path, content, { encoding: "utf-8" }, (err) => {
553
- if (err) reject(err);
554
- else resolve();
555
- });
900
+ const client = this.client;
901
+ if (!client) throw new SshDisconnectedError("SSH connection lost before SFTP write");
902
+ const write = (data, mode) => this.runSftpRequest(client, sftp, `SFTP write ${path}`, (done) => {
903
+ sftp.writeFile(path, data, { encoding: "utf-8", mode }, (err) => done(err));
556
904
  });
905
+ const chmod2 = (mode) => this.runSftpRequest(client, sftp, `SFTP chmod ${path}`, (done) => {
906
+ sftp.chmod(path, mode, (err) => done(err));
907
+ });
908
+ if (opts?.mode !== void 0) {
909
+ await write("", opts.mode);
910
+ await chmod2(opts.mode);
911
+ await write(content);
912
+ } else {
913
+ await write(content);
914
+ }
557
915
  });
558
916
  }
559
917
  async rename(from, to) {
@@ -562,10 +920,11 @@ var SshExecutor = class _SshExecutor {
562
920
  async readFile(path) {
563
921
  return this.withChannelRetry(async () => {
564
922
  const sftp = await this.sftp();
565
- return new Promise((resolve, reject) => {
923
+ const client = this.client;
924
+ if (!client) throw new SshDisconnectedError("SSH connection lost before SFTP read");
925
+ return this.runSftpRequest(client, sftp, `SFTP read ${path}`, (done) => {
566
926
  sftp.readFile(path, { encoding: "utf-8" }, (err, data) => {
567
- if (err) reject(err);
568
- else resolve(data.toString());
927
+ done(err, data?.toString());
569
928
  });
570
929
  });
571
930
  });
@@ -573,9 +932,11 @@ var SshExecutor = class _SshExecutor {
573
932
  async exists(path) {
574
933
  return this.withChannelRetry(async () => {
575
934
  const sftp = await this.sftp();
576
- return new Promise((resolve) => {
935
+ const client = this.client;
936
+ if (!client) throw new SshDisconnectedError("SSH connection lost before SFTP stat");
937
+ return this.runSftpRequest(client, sftp, `SFTP stat ${path}`, (done) => {
577
938
  sftp.stat(path, (err) => {
578
- resolve(!err);
939
+ done(null, !err);
579
940
  });
580
941
  });
581
942
  });
@@ -833,6 +1194,7 @@ var SshExecutor = class _SshExecutor {
833
1194
  async dispose() {
834
1195
  this.connecting = null;
835
1196
  this.sftpChannel = null;
1197
+ this.sftpWrapper = null;
836
1198
  this.reverseHandlers.clear();
837
1199
  this.reverseListenerClient = null;
838
1200
  if (this.client) {
@@ -976,6 +1338,7 @@ var SshExecutor = class _SshExecutor {
976
1338
  };
977
1339
 
978
1340
  // ../../packages/adapters/src/system/system-ssh-executor.ts
1341
+ import { AsyncLocalStorage as AsyncLocalStorage2 } from "async_hooks";
979
1342
  import { execFile as execFile2, spawn as spawn2 } from "child_process";
980
1343
  import { once } from "events";
981
1344
  import { mkdtemp as mkdtemp3, rm as fsRm3, stat as stat2, unlink } from "fs/promises";
@@ -1090,6 +1453,13 @@ async function openSystemSshReverseTunnel(opts) {
1090
1453
  // ../../packages/adapters/src/system/system-ssh-executor.ts
1091
1454
  var execFileAsync2 = promisify2(execFile2);
1092
1455
  var remoteDirname2 = posix2.dirname;
1456
+ function abortError2(operation, signal) {
1457
+ const reason = signal.reason;
1458
+ const suffix = reason instanceof Error && reason.message ? `: ${reason.message}` : "";
1459
+ const error = new Error(`SSH ${operation} cancelled${suffix}`);
1460
+ error.name = "AbortError";
1461
+ return error;
1462
+ }
1093
1463
  function clampWindow2(value, fallback, min, max) {
1094
1464
  if (typeof value !== "number" || !Number.isFinite(value)) return fallback;
1095
1465
  return Math.max(min, Math.min(max, Math.floor(value)));
@@ -1103,6 +1473,7 @@ function describeSshFailure(stderr, fallback) {
1103
1473
  }
1104
1474
  var SystemSshExecutor = class _SystemSshExecutor {
1105
1475
  config;
1476
+ abortScope = new AsyncLocalStorage2();
1106
1477
  controlPath = makeControlPath();
1107
1478
  /** Resolves once the ControlMaster connection is established. */
1108
1479
  masterPromise = null;
@@ -1119,6 +1490,25 @@ var SystemSshExecutor = class _SystemSshExecutor {
1119
1490
  }
1120
1491
  this.config = config;
1121
1492
  }
1493
+ runWithAbortSignal(signal, fn) {
1494
+ if (signal.aborted) return Promise.reject(abortError2("operation", signal));
1495
+ return this.abortScope.run(signal, async () => {
1496
+ this.throwIfAborted("operation");
1497
+ return fn();
1498
+ });
1499
+ }
1500
+ operationSignal() {
1501
+ return this.abortScope.getStore();
1502
+ }
1503
+ resolvedSignal(explicit) {
1504
+ const ambient = this.operationSignal();
1505
+ if (!explicit || explicit === ambient) return ambient ?? explicit;
1506
+ if (!ambient) return explicit;
1507
+ return AbortSignal.any([explicit, ambient]);
1508
+ }
1509
+ throwIfAborted(operation, signal = this.operationSignal()) {
1510
+ if (signal?.aborted) throw abortError2(operation, signal);
1511
+ }
1122
1512
  baseArgs() {
1123
1513
  return buildBaseSshArgs(this.config, this.controlPath);
1124
1514
  }
@@ -1199,7 +1589,10 @@ var SystemSshExecutor = class _SystemSshExecutor {
1199
1589
  }
1200
1590
  /** Run a remote command, resolving with stdout/stderr/exit code (never rejects on non-zero). */
1201
1591
  async runSsh(remoteCommand, opts) {
1592
+ const signal = this.operationSignal();
1593
+ this.throwIfAborted("command", signal);
1202
1594
  await this.ensureMaster();
1595
+ this.throwIfAborted("command", signal);
1203
1596
  return new Promise((resolve, reject) => {
1204
1597
  const child = spawn2("ssh", [...this.baseArgs(), sshTarget(this.config), remoteCommand], {
1205
1598
  env: sshChildEnv(this.config),
@@ -1208,7 +1601,25 @@ var SystemSshExecutor = class _SystemSshExecutor {
1208
1601
  let stdout = "";
1209
1602
  let stderr = "";
1210
1603
  let timedOut = false;
1604
+ let settled = false;
1211
1605
  let timer = null;
1606
+ const cleanup = () => {
1607
+ if (timer) clearTimeout(timer);
1608
+ signal?.removeEventListener("abort", onAbort);
1609
+ };
1610
+ const finish = (act) => {
1611
+ if (settled) return;
1612
+ settled = true;
1613
+ cleanup();
1614
+ act();
1615
+ };
1616
+ const onAbort = () => {
1617
+ try {
1618
+ child.kill("SIGKILL");
1619
+ } catch {
1620
+ }
1621
+ };
1622
+ signal?.addEventListener("abort", onAbort, { once: true });
1212
1623
  if (opts?.timeout) {
1213
1624
  timer = setTimeout(() => {
1214
1625
  timedOut = true;
@@ -1223,16 +1634,21 @@ var SystemSshExecutor = class _SystemSshExecutor {
1223
1634
  stderr += d.toString();
1224
1635
  });
1225
1636
  child.on("error", (e) => {
1226
- if (timer) clearTimeout(timer);
1227
- reject(new Error(`ssh failed to start: ${e.message}`));
1637
+ finish(() => reject(new Error(`ssh failed to start: ${e.message}`)));
1228
1638
  });
1229
1639
  child.on("close", (code) => {
1230
- if (timer) clearTimeout(timer);
1231
1640
  const c = code ?? 1;
1232
1641
  void this.maybeSignalDisconnect(c).catch(() => {
1233
1642
  });
1234
- resolve({ stdout, stderr, code: c, timedOut });
1643
+ finish(() => {
1644
+ if (signal?.aborted) reject(abortError2("command", signal));
1645
+ else resolve({ stdout, stderr, code: c, timedOut });
1646
+ });
1235
1647
  });
1648
+ if (signal?.aborted) {
1649
+ onAbort();
1650
+ return;
1651
+ }
1236
1652
  if (opts?.input !== void 0) {
1237
1653
  child.stdin.write(opts.input);
1238
1654
  }
@@ -1253,7 +1669,7 @@ var SystemSshExecutor = class _SystemSshExecutor {
1253
1669
  }
1254
1670
  async streamExec(command, onLog, opts) {
1255
1671
  await this.ensureMaster();
1256
- const signal = opts?.signal;
1672
+ const signal = this.resolvedSignal(opts?.signal);
1257
1673
  if (signal?.aborted) return { code: 0, output: "" };
1258
1674
  return new Promise((resolve) => {
1259
1675
  const child = spawn2(
@@ -1290,8 +1706,10 @@ var SystemSshExecutor = class _SystemSshExecutor {
1290
1706
  });
1291
1707
  });
1292
1708
  }
1293
- async writeFile(path, content) {
1294
- const remoteCommand = `mkdir -p ${sq(remoteDirname2(path))} && cat > ${sq(path)}`;
1709
+ async writeFile(path, content, opts) {
1710
+ const target = sq(path);
1711
+ const prepare = opts?.mode === void 0 ? `cat > ${target}` : `: > ${target} && chmod ${opts.mode.toString(8)} ${target} && cat > ${target}`;
1712
+ const remoteCommand = `mkdir -p ${sq(remoteDirname2(path))} && ${prepare}`;
1295
1713
  const res = await this.runSsh(remoteCommand, { input: content });
1296
1714
  if (res.code !== 0) {
1297
1715
  throw new Error(res.stderr.trim() || `Failed to write ${path} (exit ${res.code})`);
@@ -1691,7 +2109,7 @@ async function verifyHostChannelAuth(config) {
1691
2109
  let hint = null;
1692
2110
  let forwarding = "unknown";
1693
2111
  try {
1694
- const { connectSshClient: connectSshClient2 } = await import("./ssh-client-2XWZCMGI.js");
2112
+ const { connectSshClient: connectSshClient2 } = await import("./ssh-client-FZDWP5PQ.js");
1695
2113
  const client = await connectSshClient2({
1696
2114
  host: config.host,
1697
2115
  port: config.port,