openship 0.6.9 → 0.7.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 (58) hide show
  1. package/dist/bare-3POKLHTW.js +29 -0
  2. package/dist/{chunk-UAWY6X7V.js → chunk-3KUW2UC6.js} +470 -59
  3. package/dist/{chunk-GNOMEMNI.js → chunk-3LWGDMJU.js} +1 -1
  4. package/dist/{chunk-ENKJ7XIF.js → chunk-5CFUYMPE.js} +11 -11
  5. package/dist/{chunk-NUT6HKK6.js → chunk-5D27WZBW.js} +5 -5
  6. package/dist/{chunk-NIYD7MD2.js → chunk-7MKZP4TW.js} +89 -45
  7. package/dist/{chunk-IAMOUDHY.js → chunk-AUBB5DLY.js} +24 -15
  8. package/dist/{chunk-7PEXM2UI.js → chunk-BOD2IQ3P.js} +6 -6
  9. package/dist/{chunk-D6HU4VUZ.js → chunk-CMGRMPRQ.js} +1 -1
  10. package/dist/{chunk-MUWVC5R4.js → chunk-DZBXOF5Q.js} +11 -8
  11. package/dist/{chunk-BO6CY4V2.js → chunk-GEHV7JBS.js} +47 -4
  12. package/dist/{chunk-R5DC5PEF.js → chunk-GYDROM4W.js} +39 -10
  13. package/dist/{chunk-TAZZZKYO.js → chunk-HBQWOJRP.js} +1 -1
  14. package/dist/{chunk-WEZFTL4V.js → chunk-IVRTIKNZ.js} +183 -0
  15. package/dist/{chunk-P7RCYELG.js → chunk-J2NDOB7D.js} +1 -1
  16. package/dist/{chunk-T6DVUX4F.js → chunk-J4SYG2LC.js} +14 -3
  17. package/dist/{chunk-VAI5VOOI.js → chunk-JAKVPLHT.js} +1 -1
  18. package/dist/{chunk-NPSRGIHV.js → chunk-JWW5YAFL.js} +5 -4
  19. package/dist/{chunk-FCGJN5Q5.js → chunk-KYTTPWRP.js} +9 -9
  20. package/dist/{chunk-7YBWXEM6.js → chunk-LL7M7GL4.js} +4 -4
  21. package/dist/{chunk-SK4FFCFR.js → chunk-MZYD3LFP.js} +1 -1
  22. package/dist/{chunk-6GU6JSK6.js → chunk-QNQFS37A.js} +3 -3
  23. package/dist/{chunk-T46BQOLY.js → chunk-RHHCAWW3.js} +3 -3
  24. package/dist/{chunk-5UXHT6ED.js → chunk-SPR7N2EK.js} +113 -20
  25. package/dist/{chunk-AHJYP7DG.js → chunk-SYQ3FOLE.js} +74 -17
  26. package/dist/{chunk-3MP6ACKM.js → chunk-TPGBT5UR.js} +185 -78
  27. package/dist/{chunk-634DDFT5.js → chunk-TT7P5ZZ3.js} +8 -8
  28. package/dist/{chunk-D3OEMXYP.js → chunk-XQC6KYZD.js} +12 -7
  29. package/dist/{chunk-X53JXRS4.js → chunk-YGA52DHX.js} +3 -3
  30. package/dist/{chunk-5DJOB7IX.js → chunk-ZFA3MZ5N.js} +3 -3
  31. package/dist/{cloud-REV3DPJH.js → cloud-RFZSO6P5.js} +19 -19
  32. package/dist/{detect-BBFT5GEU.js → detect-NXD3PYBM.js} +4 -4
  33. package/dist/{docker-EG3QSE6I.js → docker-U5APUZBI.js} +10 -10
  34. package/dist/{docker-edge-executor-E5TOTLMR.js → docker-edge-executor-HUQOWFQU.js} +7 -7
  35. package/dist/{edge-import-YJVJUGYC.js → edge-import-UXD52WQR.js} +26 -26
  36. package/dist/{edge-preflight-QUW4BM7N.js → edge-preflight-3PMJ5BWM.js} +13 -13
  37. package/dist/{ensure-container-edge-FOEFQ3PI.js → ensure-container-edge-GDPY7UJD.js} +11 -11
  38. package/dist/{executor-NTWDJ3DB.js → executor-XOVSO6QZ.js} +6 -6
  39. package/dist/{host-channel-preflight-OI2TRHSG.js → host-channel-preflight-T2QUDQXU.js} +26 -26
  40. package/dist/index.js +71 -49
  41. package/dist/{local-executor-OQ2GRN7K.js → local-executor-KR7BFCJP.js} +3 -3
  42. package/dist/{nginx-4U4C2XUS.js → nginx-JMLAS7YS.js} +9 -9
  43. package/dist/{openresty-lua-LLWT5XQW.js → openresty-lua-FJH3KEC4.js} +7 -7
  44. package/dist/{privilege-MNDVMDST.js → privilege-H2QTJ5TE.js} +5 -5
  45. package/dist/server/index.js +9627 -4439
  46. package/dist/server/lua/site_logger.lua +18 -1
  47. package/dist/server/migrations/0117_github_installation_workspace_scope.sql +24 -0
  48. package/dist/server/migrations/0118_git_sources.sql +66 -0
  49. package/dist/server/migrations/0119_backup_run_batch.sql +8 -0
  50. package/dist/server/migrations/0120_domain_primary_unique.sql +24 -0
  51. package/dist/server/migrations/0121_host_port_claim_overlap.sql +14 -0
  52. package/dist/server/migrations/meta/_journal.json +35 -0
  53. package/dist/{setup-IFIMPQZJ.js → setup-ERGE5XNW.js} +12 -12
  54. package/dist/{ssh-client-2XWZCMGI.js → ssh-client-WVLLOAYK.js} +2 -2
  55. package/dist/up-EFCE6KA6.js +61 -0
  56. package/package.json +1 -1
  57. package/dist/bare-FA66N7GC.js +0 -29
  58. package/dist/up-RC5ZQIAD.js +0 -61
@@ -8,24 +8,24 @@ import {
8
8
  import {
9
9
  getTarCreateEnv,
10
10
  prepareSourceTarArgs
11
- } from "./chunk-VAI5VOOI.js";
11
+ } from "./chunk-JAKVPLHT.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-HBQWOJRP.js";
19
19
  import {
20
20
  LocalExecutor
21
- } from "./chunk-T6DVUX4F.js";
21
+ } from "./chunk-J4SYG2LC.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-J2NDOB7D.js";
29
29
  import {
30
30
  HostChannelUnavailableError,
31
31
  SshDisconnectedError,
@@ -42,13 +42,14 @@ import {
42
42
  hostChannelAccount,
43
43
  hostFirewallRule,
44
44
  safeErrorMessage
45
- } from "./chunk-WEZFTL4V.js";
45
+ } from "./chunk-IVRTIKNZ.js";
46
46
 
47
47
  // ../../packages/adapters/src/system/executor.ts
48
48
  import { existsSync, readFileSync } from "fs";
49
49
  import { networkInterfaces } from "os";
50
50
 
51
51
  // ../../packages/adapters/src/system/ssh-executor.ts
52
+ import { AsyncLocalStorage } from "async_hooks";
52
53
  import { createReadStream } from "fs";
53
54
  import { mkdtemp as mkdtemp2, rm as fsRm2, stat } from "fs/promises";
54
55
  import { tmpdir as tmpdir2 } from "os";
@@ -250,6 +251,16 @@ async function uploadFileWithRsync(localFile, remoteFile, deps, onLog, opts) {
250
251
 
251
252
  // ../../packages/adapters/src/system/ssh-executor.ts
252
253
  var remoteDirname = posix.dirname;
254
+ var SFTP_OPERATION_TIMEOUT_MS = 3e4;
255
+ var CHANNEL_CLOSE_GRACE_MS = 750;
256
+ var TRANSPORT_CLOSE_GRACE_MS = 250;
257
+ function abortError(operation, signal) {
258
+ const reason = signal.reason;
259
+ const suffix = reason instanceof Error && reason.message ? `: ${reason.message}` : "";
260
+ const error = new Error(`SSH ${operation} cancelled${suffix}`);
261
+ error.name = "AbortError";
262
+ return error;
263
+ }
253
264
  function clampWindow(value, fallback, min, max) {
254
265
  if (typeof value !== "number" || !Number.isFinite(value)) return fallback;
255
266
  return Math.max(min, Math.min(max, Math.floor(value)));
@@ -259,10 +270,19 @@ var SshExecutor = class _SshExecutor {
259
270
  connecting = null;
260
271
  /** One shared SFTP subsystem channel per client — see sftp(). */
261
272
  sftpChannel = null;
273
+ sftpWrapper = null;
274
+ /** All requests cancelled on one shared channel join the same close/reset
275
+ * barrier, so the second listener cannot miss the first listener's `close`
276
+ * event and needlessly wait for the force-reset deadline. */
277
+ sftpQuiescence = /* @__PURE__ */ new WeakMap();
262
278
  config;
279
+ /** Deployment cancellation belongs to an async call tree, not to this pooled
280
+ * executor instance. AsyncLocalStorage prevents one deployment from making
281
+ * unrelated users of the same SSH connection inherit its signal. */
282
+ abortScope = new AsyncLocalStorage();
263
283
  /** Subscribers notified when the transport drops (see onDisconnect). */
264
284
  disconnectListeners = /* @__PURE__ */ new Set();
265
- /** In-flight cancelable ops — each entry aborts ONE exec/stream on a drop,
285
+ /** In-flight cancelable ops — each entry aborts ONE exec/stream/SFTP request on a drop,
266
286
  * so a dead channel fails fast instead of hanging to its command timeout. */
267
287
  inflight = /* @__PURE__ */ new Set();
268
288
  /** Reverse-forward handlers keyed by the remote bound port (see reverseForward). */
@@ -275,15 +295,66 @@ var SshExecutor = class _SshExecutor {
275
295
  }
276
296
  this.config = config;
277
297
  }
298
+ runWithAbortSignal(signal, fn) {
299
+ if (signal.aborted) return Promise.reject(abortError("operation", signal));
300
+ return this.abortScope.run(signal, async () => {
301
+ this.throwIfAborted("operation");
302
+ return fn();
303
+ });
304
+ }
305
+ operationSignal() {
306
+ return this.abortScope.getStore();
307
+ }
308
+ throwIfAborted(operation, signal = this.operationSignal()) {
309
+ if (signal?.aborted) throw abortError(operation, signal);
310
+ }
311
+ resolvedSignal(explicit) {
312
+ const ambient = this.operationSignal();
313
+ if (!explicit || explicit === ambient) return ambient ?? explicit;
314
+ if (!ambient) return explicit;
315
+ return AbortSignal.any([explicit, ambient]);
316
+ }
317
+ /** A handshake is already bounded by ssh2's readyTimeout. A deployment
318
+ * cancellation may stop waiting sooner because no remote mutation can have
319
+ * started before connect() resolves; the shared attempt is left alive for an
320
+ * unrelated/following caller instead of tearing down pooled state. */
321
+ awaitConnection(attempt) {
322
+ const signal = this.operationSignal();
323
+ if (!signal) return attempt;
324
+ this.throwIfAborted("connection", signal);
325
+ return new Promise((resolve, reject) => {
326
+ let settled = false;
327
+ const finish = (act) => {
328
+ if (settled) return;
329
+ settled = true;
330
+ signal.removeEventListener("abort", onAbort);
331
+ act();
332
+ };
333
+ const onAbort = () => finish(() => reject(abortError("connection", signal)));
334
+ signal.addEventListener("abort", onAbort, { once: true });
335
+ if (signal.aborted) {
336
+ onAbort();
337
+ return;
338
+ }
339
+ attempt.then(
340
+ (client) => finish(() => resolve(client)),
341
+ (error) => finish(() => reject(error))
342
+ );
343
+ });
344
+ }
278
345
  async connect() {
279
- if (this.client) return this.client;
280
- if (this.connecting) return this.connecting;
346
+ if (this.client) {
347
+ this.throwIfAborted("connection");
348
+ return this.client;
349
+ }
350
+ if (this.connecting) return this.awaitConnection(this.connecting);
281
351
  const attempt = (async () => {
282
352
  const client = await connectSshClient(this.config);
283
353
  const onTransportDown = (cause) => {
284
354
  if (this.client !== client) return;
285
355
  this.client = null;
286
356
  this.sftpChannel = null;
357
+ this.sftpWrapper = null;
287
358
  this.handleDisconnect(cause);
288
359
  };
289
360
  client.on("close", () => onTransportDown());
@@ -297,7 +368,7 @@ var SshExecutor = class _SshExecutor {
297
368
  if (this.connecting === attempt) this.connecting = null;
298
369
  };
299
370
  attempt.then(clearMemo, clearMemo);
300
- return attempt;
371
+ return this.awaitConnection(attempt);
301
372
  }
302
373
  /**
303
374
  * Subscribe to transport-level disconnects. Returns an unsubscribe fn.
@@ -318,6 +389,15 @@ var SshExecutor = class _SshExecutor {
318
389
  const err = new SshDisconnectedError(
319
390
  cause?.message ? `SSH connection lost: ${cause.message}` : "SSH connection lost"
320
391
  );
392
+ this.rejectInflight(err);
393
+ for (const cb of [...this.disconnectListeners]) {
394
+ try {
395
+ cb(err);
396
+ } catch {
397
+ }
398
+ }
399
+ }
400
+ rejectInflight(err) {
321
401
  const aborts = [...this.inflight];
322
402
  this.inflight.clear();
323
403
  for (const abort of aborts) {
@@ -326,12 +406,136 @@ var SshExecutor = class _SshExecutor {
326
406
  } catch {
327
407
  }
328
408
  }
329
- for (const cb of [...this.disconnectListeners]) {
409
+ }
410
+ async waitForChannelClose(channel, close) {
411
+ return new Promise((resolve) => {
412
+ let settled = false;
413
+ const finish = (closed) => {
414
+ if (settled) return;
415
+ settled = true;
416
+ clearTimeout(timer);
417
+ channel.removeListener("close", onClose);
418
+ channel.removeListener("error", onError);
419
+ resolve(closed);
420
+ };
421
+ const onClose = () => finish(true);
422
+ const onError = () => {
423
+ };
424
+ const timer = setTimeout(() => finish(false), CHANNEL_CLOSE_GRACE_MS);
425
+ timer.unref?.();
426
+ channel.once("close", onClose);
427
+ channel.once("error", onError);
330
428
  try {
331
- cb(err);
429
+ close();
332
430
  } catch {
431
+ finish(false);
333
432
  }
433
+ });
434
+ }
435
+ /** Hard transport reset used only when a channel cannot prove it closed.
436
+ * Every operation on that transport is rejected before we return, and a
437
+ * later operation must establish a fresh connection. */
438
+ async forceResetTransport(client, cause) {
439
+ if (this.client !== client) return;
440
+ this.client = null;
441
+ this.connecting = null;
442
+ this.sftpChannel = null;
443
+ this.sftpWrapper = null;
444
+ if (this.reverseListenerClient === client) this.reverseListenerClient = null;
445
+ this.rejectInflight(
446
+ new SshDisconnectedError(`SSH connection reset to quiesce an operation: ${cause.message}`)
447
+ );
448
+ let closed = false;
449
+ const closePromise = new Promise((resolve) => {
450
+ const done = () => {
451
+ if (closed) return;
452
+ closed = true;
453
+ resolve();
454
+ };
455
+ client.once("close", done);
456
+ client.once("end", done);
457
+ });
458
+ try {
459
+ client.end();
460
+ } catch {
461
+ }
462
+ try {
463
+ client.destroy();
464
+ } catch {
465
+ }
466
+ await Promise.race([
467
+ closePromise,
468
+ new Promise((resolve) => {
469
+ const timer = setTimeout(resolve, TRANSPORT_CLOSE_GRACE_MS);
470
+ timer.unref?.();
471
+ })
472
+ ]);
473
+ }
474
+ quiesceSftp(client, wrapper, cause) {
475
+ if (wrapper) {
476
+ const existing = this.sftpQuiescence.get(wrapper);
477
+ if (existing) return existing;
478
+ }
479
+ const quiescence = this.quiesceSftpOnce(client, wrapper, cause);
480
+ if (wrapper) this.sftpQuiescence.set(wrapper, quiescence);
481
+ return quiescence;
482
+ }
483
+ async quiesceSftpOnce(client, wrapper, cause) {
484
+ if (!wrapper || this.sftpWrapper === wrapper) {
485
+ this.sftpChannel = null;
486
+ this.sftpWrapper = null;
487
+ }
488
+ if (wrapper) {
489
+ const closed = await this.waitForChannelClose(wrapper, () => wrapper.end());
490
+ if (closed) return;
334
491
  }
492
+ await this.forceResetTransport(client, cause);
493
+ }
494
+ awaitSftpOpen(opening, client) {
495
+ const signal = this.operationSignal();
496
+ this.throwIfAborted("SFTP channel open", signal);
497
+ return new Promise((resolve, reject) => {
498
+ let settled = false;
499
+ let terminating = false;
500
+ const cleanup = () => {
501
+ clearTimeout(timer);
502
+ this.inflight.delete(abort);
503
+ signal?.removeEventListener("abort", onSignalAbort);
504
+ };
505
+ const finish = (act) => {
506
+ if (settled || terminating) return;
507
+ settled = true;
508
+ cleanup();
509
+ act();
510
+ };
511
+ const terminate = (error) => {
512
+ if (settled || terminating) return;
513
+ terminating = true;
514
+ cleanup();
515
+ const settle = () => {
516
+ settled = true;
517
+ reject(error);
518
+ };
519
+ void this.forceResetTransport(client, error).then(settle, settle);
520
+ };
521
+ const abort = (error) => terminate(error);
522
+ const onSignalAbort = () => terminate(abortError("SFTP channel open", signal));
523
+ const timer = setTimeout(
524
+ () => terminate(new Error(`SFTP channel open timed out after ${SFTP_OPERATION_TIMEOUT_MS}ms`)),
525
+ SFTP_OPERATION_TIMEOUT_MS
526
+ );
527
+ timer.unref?.();
528
+ this.inflight.add(abort);
529
+ signal?.addEventListener("abort", onSignalAbort, { once: true });
530
+ if (signal?.aborted) {
531
+ onSignalAbort();
532
+ return;
533
+ }
534
+ opening.then(
535
+ (wrapper) => finish(() => resolve(wrapper)),
536
+ (error) => finish(() => reject(error))
537
+ );
538
+ });
335
539
  }
336
540
  /**
337
541
  * One SFTP subsystem channel per client, opened lazily and shared by every
@@ -344,26 +548,39 @@ var SshExecutor = class _SshExecutor {
344
548
  */
345
549
  async sftp() {
346
550
  const client = await this.connect();
347
- if (this.sftpChannel) return this.sftpChannel;
551
+ this.throwIfAborted("SFTP channel open");
552
+ if (this.sftpChannel) return this.awaitSftpOpen(this.sftpChannel, client);
348
553
  const opening = openSftp(client).then((wrapper) => {
554
+ if (this.client !== client || this.sftpChannel !== opening) {
555
+ try {
556
+ wrapper.end();
557
+ } catch {
558
+ }
559
+ throw new SshDisconnectedError("SFTP channel opened after its SSH transport was reset");
560
+ }
349
561
  const drop = () => {
350
- if (this.sftpChannel === opening) this.sftpChannel = null;
562
+ if (this.sftpChannel === opening) {
563
+ this.sftpChannel = null;
564
+ this.sftpWrapper = null;
565
+ }
351
566
  };
352
567
  wrapper.once("close", drop);
353
568
  wrapper.once("error", drop);
569
+ this.sftpWrapper = wrapper;
354
570
  return wrapper;
355
571
  });
356
572
  opening.catch(() => {
357
573
  if (this.sftpChannel === opening) this.sftpChannel = null;
358
574
  });
359
575
  this.sftpChannel = opening;
360
- return opening;
576
+ return this.awaitSftpOpen(opening, client);
361
577
  }
362
578
  /** Close + forget the shared SFTP channel (frees its session) without
363
579
  * touching the SSH client, so file ops can reopen on the same connection. */
364
580
  dropSftp() {
365
581
  const ch = this.sftpChannel;
366
582
  this.sftpChannel = null;
583
+ this.sftpWrapper = null;
367
584
  if (ch) ch.then((w) => {
368
585
  try {
369
586
  w.end();
@@ -382,6 +599,10 @@ var SshExecutor = class _SshExecutor {
382
599
  this.client.end();
383
600
  } catch {
384
601
  }
602
+ try {
603
+ this.client.destroy();
604
+ } catch {
605
+ }
385
606
  this.client = null;
386
607
  }
387
608
  this.connecting = null;
@@ -413,10 +634,11 @@ var SshExecutor = class _SshExecutor {
413
634
  * write fails spuriously and only succeeds on a manual redeploy.
414
635
  */
415
636
  async withChannelRetry(fn) {
637
+ this.throwIfAborted("operation");
416
638
  try {
417
639
  return await fn();
418
640
  } catch (err) {
419
- if (_SshExecutor.isChannelError(err)) {
641
+ if (_SshExecutor.isChannelError(err) && !this.operationSignal()?.aborted) {
420
642
  this.recoverFromChannelError();
421
643
  return fn();
422
644
  }
@@ -431,26 +653,65 @@ var SshExecutor = class _SshExecutor {
431
653
  async _exec(command, opts) {
432
654
  const client = await this.connect();
433
655
  const timeout = opts?.timeout ?? 3e4;
656
+ const signal = this.operationSignal();
657
+ this.throwIfAborted("command", signal);
434
658
  return new Promise((resolve, reject) => {
435
659
  let settled = false;
660
+ let terminating = false;
436
661
  let timer = null;
437
- const abort = (err) => finish(() => reject(err));
438
- const finish = (act) => {
439
- if (settled) return;
440
- settled = true;
662
+ let channel = null;
663
+ const cleanup = () => {
441
664
  if (timer) clearTimeout(timer);
442
665
  this.inflight.delete(abort);
666
+ signal?.removeEventListener("abort", onSignalAbort);
667
+ };
668
+ const finish = (act) => {
669
+ if (settled || terminating) return;
670
+ settled = true;
671
+ cleanup();
443
672
  act();
444
673
  };
674
+ const terminate = (error) => {
675
+ if (settled || terminating) return;
676
+ terminating = true;
677
+ cleanup();
678
+ const quiesce = (async () => {
679
+ if (channel) {
680
+ const closed = await this.waitForChannelClose(channel, () => channel.close());
681
+ if (!closed) await this.forceResetTransport(client, error);
682
+ } else {
683
+ await this.forceResetTransport(client, error);
684
+ }
685
+ })();
686
+ const settle = () => {
687
+ settled = true;
688
+ reject(error);
689
+ };
690
+ void quiesce.then(settle, settle);
691
+ };
692
+ const abort = (err) => terminate(err);
693
+ const onSignalAbort = () => terminate(abortError("command", signal));
445
694
  this.inflight.add(abort);
695
+ signal?.addEventListener("abort", onSignalAbort, { once: true });
696
+ if (signal?.aborted) {
697
+ onSignalAbort();
698
+ return;
699
+ }
446
700
  timer = setTimeout(
447
- () => finish(
448
- () => reject(new Error(`Command timed out after ${timeout}ms: ${commandForError(command)}`))
449
- ),
701
+ () => terminate(new Error(`Command timed out after ${timeout}ms: ${commandForError(command)}`)),
450
702
  timeout
451
703
  );
704
+ timer.unref?.();
452
705
  client.exec(_SshExecutor.ENV_PREFIX + command, (err, stream) => {
453
706
  if (err) return finish(() => reject(err));
707
+ channel = stream;
708
+ if (terminating) {
709
+ try {
710
+ stream.close();
711
+ } catch {
712
+ }
713
+ return;
714
+ }
454
715
  let stdout = "";
455
716
  let stderr = "";
456
717
  stream.on("data", (data) => {
@@ -459,6 +720,7 @@ var SshExecutor = class _SshExecutor {
459
720
  stream.stderr.on("data", (data) => {
460
721
  stderr += data.toString();
461
722
  });
723
+ stream.on("error", (error) => finish(() => reject(error)));
462
724
  stream.on("close", (code) => {
463
725
  finish(() => {
464
726
  if (code !== 0) {
@@ -471,10 +733,11 @@ var SshExecutor = class _SshExecutor {
471
733
  });
472
734
  }
473
735
  streamExec(command, onLog, opts) {
474
- return this._streamExec(command, onLog, opts).catch((err) => {
475
- if (_SshExecutor.isChannelError(err)) {
736
+ const scopedOpts = { ...opts, signal: this.resolvedSignal(opts?.signal) };
737
+ return this._streamExec(command, onLog, scopedOpts).catch((err) => {
738
+ if (_SshExecutor.isChannelError(err) && !scopedOpts.signal?.aborted) {
476
739
  this.recoverFromChannelError();
477
- return this._streamExec(command, onLog, opts);
740
+ return this._streamExec(command, onLog, scopedOpts);
478
741
  }
479
742
  throw err;
480
743
  });
@@ -484,33 +747,56 @@ var SshExecutor = class _SshExecutor {
484
747
  const signal = opts?.signal;
485
748
  return new Promise((resolve, reject) => {
486
749
  let settled = false;
750
+ let terminating = false;
487
751
  const chunks = [];
488
- const abort = (err) => finish(() => reject(err));
489
- const finish = (act) => {
490
- if (settled) return;
491
- settled = true;
752
+ let channel = null;
753
+ const cleanup = () => {
492
754
  this.inflight.delete(abort);
493
755
  signal?.removeEventListener("abort", onSignalAbort);
756
+ };
757
+ const finish = (act) => {
758
+ if (settled || terminating) return;
759
+ settled = true;
760
+ cleanup();
494
761
  act();
495
762
  };
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
- }
763
+ const terminate = (error, intentional) => {
764
+ if (settled || terminating) return;
765
+ terminating = true;
766
+ cleanup();
767
+ const quiesce = (async () => {
768
+ if (channel) {
769
+ const closed = await this.waitForChannelClose(channel, () => channel.close());
770
+ if (!closed) await this.forceResetTransport(client, error);
771
+ } else {
772
+ await this.forceResetTransport(client, error);
773
+ }
774
+ })();
775
+ const settle = () => {
776
+ settled = true;
777
+ if (intentional) resolve({ code: 0, output: chunks.join("") });
778
+ else reject(error);
779
+ };
780
+ void quiesce.then(settle, settle);
504
781
  };
782
+ const abort = (err) => terminate(err, false);
783
+ const onSignalAbort = () => terminate(abortError("streaming command", signal), true);
784
+ this.inflight.add(abort);
785
+ signal?.addEventListener("abort", onSignalAbort, { once: true });
505
786
  if (signal?.aborted) {
506
- finish(() => resolve({ code: 0, output: "" }));
787
+ onSignalAbort();
507
788
  return;
508
789
  }
509
790
  client.exec(_SshExecutor.ENV_PREFIX + command, (err, stream) => {
510
791
  if (err) return finish(() => reject(err));
511
792
  channel = stream;
512
- if (signal?.aborted) return onSignalAbort();
513
- signal?.addEventListener("abort", onSignalAbort, { once: true });
793
+ if (terminating || signal?.aborted) {
794
+ try {
795
+ stream.close();
796
+ } catch {
797
+ }
798
+ return;
799
+ }
514
800
  const onChunk = (data, level) => {
515
801
  const text = data.toString();
516
802
  if (!text) return;
@@ -519,6 +805,7 @@ var SshExecutor = class _SshExecutor {
519
805
  };
520
806
  stream.on("data", (data) => onChunk(data, "info"));
521
807
  stream.stderr.on("data", (data) => onChunk(data, "warn"));
808
+ stream.on("error", (error) => finish(() => reject(error)));
522
809
  let exitCode = null;
523
810
  stream.on("exit", (code) => {
524
811
  exitCode = code;
@@ -540,7 +827,62 @@ var SshExecutor = class _SshExecutor {
540
827
  });
541
828
  });
542
829
  }
543
- async writeFile(path, content) {
830
+ /** Turn ssh2's callback-only SFTP request into a bounded operation. A lost
831
+ * callback used to leave the compose deploy promise and its host mutex tail
832
+ * pending forever. Cancellation/timeout closes the shared subsystem first;
833
+ * if close itself wedges, the parent SSH transport is destroyed before this
834
+ * promise rejects. */
835
+ runSftpRequest(client, sftp, operation, start) {
836
+ const signal = this.operationSignal();
837
+ this.throwIfAborted(operation, signal);
838
+ return new Promise((resolve, reject) => {
839
+ let settled = false;
840
+ let terminating = false;
841
+ const cleanup = () => {
842
+ clearTimeout(timer);
843
+ this.inflight.delete(abort);
844
+ signal?.removeEventListener("abort", onSignalAbort);
845
+ };
846
+ const finish = (act) => {
847
+ if (settled || terminating) return;
848
+ settled = true;
849
+ cleanup();
850
+ act();
851
+ };
852
+ const terminate = (error) => {
853
+ if (settled || terminating) return;
854
+ terminating = true;
855
+ cleanup();
856
+ const settle = () => {
857
+ settled = true;
858
+ reject(error);
859
+ };
860
+ void this.quiesceSftp(client, sftp, error).then(settle, settle);
861
+ };
862
+ const abort = (error) => terminate(error);
863
+ const onSignalAbort = () => terminate(abortError(operation, signal));
864
+ const timer = setTimeout(
865
+ () => terminate(new Error(`${operation} timed out after ${SFTP_OPERATION_TIMEOUT_MS}ms`)),
866
+ SFTP_OPERATION_TIMEOUT_MS
867
+ );
868
+ timer.unref?.();
869
+ this.inflight.add(abort);
870
+ signal?.addEventListener("abort", onSignalAbort, { once: true });
871
+ if (signal?.aborted) {
872
+ onSignalAbort();
873
+ return;
874
+ }
875
+ try {
876
+ start((error, value) => {
877
+ if (error) finish(() => reject(error));
878
+ else finish(() => resolve(value));
879
+ });
880
+ } catch (error) {
881
+ finish(() => reject(error));
882
+ }
883
+ });
884
+ }
885
+ async writeFile(path, content, opts) {
544
886
  const dir = remoteDirname(path);
545
887
  try {
546
888
  await this.exec(`mkdir -p ${sq(dir)}`);
@@ -548,12 +890,21 @@ var SshExecutor = class _SshExecutor {
548
890
  }
549
891
  return this.withChannelRetry(async () => {
550
892
  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
- });
893
+ const client = this.client;
894
+ if (!client) throw new SshDisconnectedError("SSH connection lost before SFTP write");
895
+ const write = (data, mode) => this.runSftpRequest(client, sftp, `SFTP write ${path}`, (done) => {
896
+ sftp.writeFile(path, data, { encoding: "utf-8", mode }, (err) => done(err));
556
897
  });
898
+ const chmod2 = (mode) => this.runSftpRequest(client, sftp, `SFTP chmod ${path}`, (done) => {
899
+ sftp.chmod(path, mode, (err) => done(err));
900
+ });
901
+ if (opts?.mode !== void 0) {
902
+ await write("", opts.mode);
903
+ await chmod2(opts.mode);
904
+ await write(content);
905
+ } else {
906
+ await write(content);
907
+ }
557
908
  });
558
909
  }
559
910
  async rename(from, to) {
@@ -562,10 +913,11 @@ var SshExecutor = class _SshExecutor {
562
913
  async readFile(path) {
563
914
  return this.withChannelRetry(async () => {
564
915
  const sftp = await this.sftp();
565
- return new Promise((resolve, reject) => {
916
+ const client = this.client;
917
+ if (!client) throw new SshDisconnectedError("SSH connection lost before SFTP read");
918
+ return this.runSftpRequest(client, sftp, `SFTP read ${path}`, (done) => {
566
919
  sftp.readFile(path, { encoding: "utf-8" }, (err, data) => {
567
- if (err) reject(err);
568
- else resolve(data.toString());
920
+ done(err, data?.toString());
569
921
  });
570
922
  });
571
923
  });
@@ -573,9 +925,11 @@ var SshExecutor = class _SshExecutor {
573
925
  async exists(path) {
574
926
  return this.withChannelRetry(async () => {
575
927
  const sftp = await this.sftp();
576
- return new Promise((resolve) => {
928
+ const client = this.client;
929
+ if (!client) throw new SshDisconnectedError("SSH connection lost before SFTP stat");
930
+ return this.runSftpRequest(client, sftp, `SFTP stat ${path}`, (done) => {
577
931
  sftp.stat(path, (err) => {
578
- resolve(!err);
932
+ done(null, !err);
579
933
  });
580
934
  });
581
935
  });
@@ -833,6 +1187,7 @@ var SshExecutor = class _SshExecutor {
833
1187
  async dispose() {
834
1188
  this.connecting = null;
835
1189
  this.sftpChannel = null;
1190
+ this.sftpWrapper = null;
836
1191
  this.reverseHandlers.clear();
837
1192
  this.reverseListenerClient = null;
838
1193
  if (this.client) {
@@ -976,6 +1331,7 @@ var SshExecutor = class _SshExecutor {
976
1331
  };
977
1332
 
978
1333
  // ../../packages/adapters/src/system/system-ssh-executor.ts
1334
+ import { AsyncLocalStorage as AsyncLocalStorage2 } from "async_hooks";
979
1335
  import { execFile as execFile2, spawn as spawn2 } from "child_process";
980
1336
  import { once } from "events";
981
1337
  import { mkdtemp as mkdtemp3, rm as fsRm3, stat as stat2, unlink } from "fs/promises";
@@ -1090,6 +1446,13 @@ async function openSystemSshReverseTunnel(opts) {
1090
1446
  // ../../packages/adapters/src/system/system-ssh-executor.ts
1091
1447
  var execFileAsync2 = promisify2(execFile2);
1092
1448
  var remoteDirname2 = posix2.dirname;
1449
+ function abortError2(operation, signal) {
1450
+ const reason = signal.reason;
1451
+ const suffix = reason instanceof Error && reason.message ? `: ${reason.message}` : "";
1452
+ const error = new Error(`SSH ${operation} cancelled${suffix}`);
1453
+ error.name = "AbortError";
1454
+ return error;
1455
+ }
1093
1456
  function clampWindow2(value, fallback, min, max) {
1094
1457
  if (typeof value !== "number" || !Number.isFinite(value)) return fallback;
1095
1458
  return Math.max(min, Math.min(max, Math.floor(value)));
@@ -1103,6 +1466,7 @@ function describeSshFailure(stderr, fallback) {
1103
1466
  }
1104
1467
  var SystemSshExecutor = class _SystemSshExecutor {
1105
1468
  config;
1469
+ abortScope = new AsyncLocalStorage2();
1106
1470
  controlPath = makeControlPath();
1107
1471
  /** Resolves once the ControlMaster connection is established. */
1108
1472
  masterPromise = null;
@@ -1119,6 +1483,25 @@ var SystemSshExecutor = class _SystemSshExecutor {
1119
1483
  }
1120
1484
  this.config = config;
1121
1485
  }
1486
+ runWithAbortSignal(signal, fn) {
1487
+ if (signal.aborted) return Promise.reject(abortError2("operation", signal));
1488
+ return this.abortScope.run(signal, async () => {
1489
+ this.throwIfAborted("operation");
1490
+ return fn();
1491
+ });
1492
+ }
1493
+ operationSignal() {
1494
+ return this.abortScope.getStore();
1495
+ }
1496
+ resolvedSignal(explicit) {
1497
+ const ambient = this.operationSignal();
1498
+ if (!explicit || explicit === ambient) return ambient ?? explicit;
1499
+ if (!ambient) return explicit;
1500
+ return AbortSignal.any([explicit, ambient]);
1501
+ }
1502
+ throwIfAborted(operation, signal = this.operationSignal()) {
1503
+ if (signal?.aborted) throw abortError2(operation, signal);
1504
+ }
1122
1505
  baseArgs() {
1123
1506
  return buildBaseSshArgs(this.config, this.controlPath);
1124
1507
  }
@@ -1199,7 +1582,10 @@ var SystemSshExecutor = class _SystemSshExecutor {
1199
1582
  }
1200
1583
  /** Run a remote command, resolving with stdout/stderr/exit code (never rejects on non-zero). */
1201
1584
  async runSsh(remoteCommand, opts) {
1585
+ const signal = this.operationSignal();
1586
+ this.throwIfAborted("command", signal);
1202
1587
  await this.ensureMaster();
1588
+ this.throwIfAborted("command", signal);
1203
1589
  return new Promise((resolve, reject) => {
1204
1590
  const child = spawn2("ssh", [...this.baseArgs(), sshTarget(this.config), remoteCommand], {
1205
1591
  env: sshChildEnv(this.config),
@@ -1208,7 +1594,25 @@ var SystemSshExecutor = class _SystemSshExecutor {
1208
1594
  let stdout = "";
1209
1595
  let stderr = "";
1210
1596
  let timedOut = false;
1597
+ let settled = false;
1211
1598
  let timer = null;
1599
+ const cleanup = () => {
1600
+ if (timer) clearTimeout(timer);
1601
+ signal?.removeEventListener("abort", onAbort);
1602
+ };
1603
+ const finish = (act) => {
1604
+ if (settled) return;
1605
+ settled = true;
1606
+ cleanup();
1607
+ act();
1608
+ };
1609
+ const onAbort = () => {
1610
+ try {
1611
+ child.kill("SIGKILL");
1612
+ } catch {
1613
+ }
1614
+ };
1615
+ signal?.addEventListener("abort", onAbort, { once: true });
1212
1616
  if (opts?.timeout) {
1213
1617
  timer = setTimeout(() => {
1214
1618
  timedOut = true;
@@ -1223,16 +1627,21 @@ var SystemSshExecutor = class _SystemSshExecutor {
1223
1627
  stderr += d.toString();
1224
1628
  });
1225
1629
  child.on("error", (e) => {
1226
- if (timer) clearTimeout(timer);
1227
- reject(new Error(`ssh failed to start: ${e.message}`));
1630
+ finish(() => reject(new Error(`ssh failed to start: ${e.message}`)));
1228
1631
  });
1229
1632
  child.on("close", (code) => {
1230
- if (timer) clearTimeout(timer);
1231
1633
  const c = code ?? 1;
1232
1634
  void this.maybeSignalDisconnect(c).catch(() => {
1233
1635
  });
1234
- resolve({ stdout, stderr, code: c, timedOut });
1636
+ finish(() => {
1637
+ if (signal?.aborted) reject(abortError2("command", signal));
1638
+ else resolve({ stdout, stderr, code: c, timedOut });
1639
+ });
1235
1640
  });
1641
+ if (signal?.aborted) {
1642
+ onAbort();
1643
+ return;
1644
+ }
1236
1645
  if (opts?.input !== void 0) {
1237
1646
  child.stdin.write(opts.input);
1238
1647
  }
@@ -1253,7 +1662,7 @@ var SystemSshExecutor = class _SystemSshExecutor {
1253
1662
  }
1254
1663
  async streamExec(command, onLog, opts) {
1255
1664
  await this.ensureMaster();
1256
- const signal = opts?.signal;
1665
+ const signal = this.resolvedSignal(opts?.signal);
1257
1666
  if (signal?.aborted) return { code: 0, output: "" };
1258
1667
  return new Promise((resolve) => {
1259
1668
  const child = spawn2(
@@ -1290,8 +1699,10 @@ var SystemSshExecutor = class _SystemSshExecutor {
1290
1699
  });
1291
1700
  });
1292
1701
  }
1293
- async writeFile(path, content) {
1294
- const remoteCommand = `mkdir -p ${sq(remoteDirname2(path))} && cat > ${sq(path)}`;
1702
+ async writeFile(path, content, opts) {
1703
+ const target = sq(path);
1704
+ const prepare = opts?.mode === void 0 ? `cat > ${target}` : `: > ${target} && chmod ${opts.mode.toString(8)} ${target} && cat > ${target}`;
1705
+ const remoteCommand = `mkdir -p ${sq(remoteDirname2(path))} && ${prepare}`;
1295
1706
  const res = await this.runSsh(remoteCommand, { input: content });
1296
1707
  if (res.code !== 0) {
1297
1708
  throw new Error(res.stderr.trim() || `Failed to write ${path} (exit ${res.code})`);
@@ -1691,7 +2102,7 @@ async function verifyHostChannelAuth(config) {
1691
2102
  let hint = null;
1692
2103
  let forwarding = "unknown";
1693
2104
  try {
1694
- const { connectSshClient: connectSshClient2 } = await import("./ssh-client-2XWZCMGI.js");
2105
+ const { connectSshClient: connectSshClient2 } = await import("./ssh-client-WVLLOAYK.js");
1695
2106
  const client = await connectSshClient2({
1696
2107
  host: config.host,
1697
2108
  port: config.port,