@indigoai-us/hq-cloud 6.18.27 → 6.18.29

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 (50) hide show
  1. package/dist/cli/rescue-breaker.test.js +204 -4
  2. package/dist/cli/rescue-breaker.test.js.map +1 -1
  3. package/dist/cli/rescue-core.d.ts +6 -1
  4. package/dist/cli/rescue-core.d.ts.map +1 -1
  5. package/dist/cli/rescue-core.js +258 -151
  6. package/dist/cli/rescue-core.js.map +1 -1
  7. package/dist/cli/rescue-preserve-copy-symlink.test.js +3 -8
  8. package/dist/cli/rescue-preserve-copy-symlink.test.js.map +1 -1
  9. package/dist/cli/rescue-rsync-status.test.js +28 -2
  10. package/dist/cli/rescue-rsync-status.test.js.map +1 -1
  11. package/dist/cli/rescue-snapshot-copy.test.js +147 -41
  12. package/dist/cli/rescue-snapshot-copy.test.js.map +1 -1
  13. package/dist/cognito-auth.d.ts +5 -1
  14. package/dist/cognito-auth.d.ts.map +1 -1
  15. package/dist/cognito-auth.js +40 -6
  16. package/dist/cognito-auth.js.map +1 -1
  17. package/dist/cognito-auth.test.js +45 -0
  18. package/dist/cognito-auth.test.js.map +1 -1
  19. package/dist/outposts/command.d.ts.map +1 -1
  20. package/dist/outposts/command.js +26 -5
  21. package/dist/outposts/command.js.map +1 -1
  22. package/dist/outposts/command.test.js +30 -0
  23. package/dist/outposts/command.test.js.map +1 -1
  24. package/dist/outposts/session-host-auth.d.ts +3 -2
  25. package/dist/outposts/session-host-auth.d.ts.map +1 -1
  26. package/dist/outposts/session-host-command.d.ts +5 -0
  27. package/dist/outposts/session-host-command.d.ts.map +1 -1
  28. package/dist/outposts/session-host-command.js +11 -6
  29. package/dist/outposts/session-host-command.js.map +1 -1
  30. package/dist/outposts/session-host-command.test.js +52 -2
  31. package/dist/outposts/session-host-command.test.js.map +1 -1
  32. package/dist/outposts/session-host-fakes.d.ts +1 -1
  33. package/dist/outposts/session-host-fakes.d.ts.map +1 -1
  34. package/dist/outposts/session-host-fakes.js +1 -1
  35. package/dist/outposts/session-host-fakes.js.map +1 -1
  36. package/dist/outposts/session-host-protocol.d.ts +11 -12
  37. package/dist/outposts/session-host-protocol.d.ts.map +1 -1
  38. package/dist/outposts/session-host-protocol.js +1 -1
  39. package/dist/outposts/session-host-protocol.js.map +1 -1
  40. package/dist/outposts/session-host-sessions.d.ts +2 -1
  41. package/dist/outposts/session-host-sessions.d.ts.map +1 -1
  42. package/dist/signals/internals.d.ts +0 -6
  43. package/dist/signals/internals.d.ts.map +1 -1
  44. package/dist/signals/internals.js +3 -14
  45. package/dist/signals/internals.js.map +1 -1
  46. package/dist/sources/internals.d.ts +0 -4
  47. package/dist/sources/internals.d.ts.map +1 -1
  48. package/dist/sources/internals.js +3 -3
  49. package/dist/sources/internals.js.map +1 -1
  50. package/package.json +1 -1
@@ -149,6 +149,8 @@ const GIT_NETWORK_RETRY_DELAYS_MS = [1_000, 2_000];
149
149
  // libcurl's low-speed pair (see gitNetworkEnv) aborts a transfer that stalls,
150
150
  // and a slow transfer that is still moving is allowed to finish.
151
151
  const GIT_REACHABILITY_PROBE_TIMEOUT_MS = 90_000;
152
+ const SNAPSHOT_CLONE_METADATA_ALLOWANCE_BYTES = 64 * 1024 ** 2;
153
+ const CLOUD_UPDATE_OVERLAY_FREE_SPACE_FLOOR_BYTES = 16 * 1024 ** 2;
152
154
  function gitNetworkEnv(env) {
153
155
  return {
154
156
  ...env,
@@ -731,7 +733,7 @@ export function runRescue(argv, opts = {}) {
731
733
  tmpdir = d;
732
734
  }, () => {
733
735
  retainTmpdir = true;
734
- }, opts.gitReachabilityProbeTimeoutMs ?? GIT_REACHABILITY_PROBE_TIMEOUT_MS, opts.coreSourceStampWriter, opts.restoreOnRsyncFailure === true, opts.backupCapacityStatfsSync ?? fs.statfsSync);
736
+ }, opts.gitReachabilityProbeTimeoutMs ?? GIT_REACHABILITY_PROBE_TIMEOUT_MS, opts.coreSourceStampWriter, opts.restoreOnRsyncFailure === true, opts.backupCapacityStatfsSync ?? fs.statfsSync, opts.backupCapacityCloneProbe ?? probeCopyOnWriteClone, opts.backupCapacityStatSync ?? fs.statSync);
735
737
  }
736
738
  catch (e) {
737
739
  if (e instanceof ExitError)
@@ -1463,7 +1465,7 @@ function hashRecoveryTree(root, rootRel) {
1463
1465
  }
1464
1466
  return { kind: "present", digest: hash.digest("hex") };
1465
1467
  }
1466
- function rsyncOutputHasOnlyDirectoryChanges(output) {
1468
+ export function rsyncOutputHasOnlyDirectoryChanges(output) {
1467
1469
  for (const rawLine of output.split(/\r?\n/)) {
1468
1470
  const line = rawLine.trim();
1469
1471
  if (!line)
@@ -1476,9 +1478,20 @@ function rsyncOutputHasOnlyDirectoryChanges(output) {
1476
1478
  }
1477
1479
  if (line.startsWith("*deleting "))
1478
1480
  return false;
1479
- const itemized = /^([<>ch.*])([fdLDS])[^ ]*\s+/.exec(line);
1480
- if (!itemized || itemized[2] !== "d")
1481
+ const itemized = /^([<>ch.*])([fdLDS])([.cstpoguaxT]{7}(?:[.cstpoguaxT]{2})?)\s+/.exec(line);
1482
+ if (!itemized)
1481
1483
  return false;
1484
+ if (itemized[2] === "d")
1485
+ continue;
1486
+ // `--checksum --no-times` can still itemize differing mtimes as `.f..T....`.
1487
+ // Checksum comparison has already proved the file bytes match; ignore that
1488
+ // metadata-only difference while keeping every content change fail-closed.
1489
+ if (itemized[1] === "." &&
1490
+ itemized[2] === "f" &&
1491
+ [...itemized[3]].every((flag, index) => flag === "." || (index === 2 && flag === "T"))) {
1492
+ continue;
1493
+ }
1494
+ return false;
1482
1495
  }
1483
1496
  return true;
1484
1497
  }
@@ -1523,7 +1536,7 @@ function recoveryProof(unresolved, cfg, hqRoot, srcDir, srcSha, rsyncLauncher, r
1523
1536
  }
1524
1537
  return { eligible: !hasSkippedSnapshotEntries(newest.entry.manifest), failures };
1525
1538
  }
1526
- function doRescue(cfg, env, out, err, setTmp, retainTmp, gitReachabilityProbeTimeoutMs = GIT_REACHABILITY_PROBE_TIMEOUT_MS, stampWriter = writeCoreSourceStamp, restoreOnRsyncFailure = false, backupCapacityStatfsSync = fs.statfsSync) {
1539
+ function doRescue(cfg, env, out, err, setTmp, retainTmp, gitReachabilityProbeTimeoutMs = GIT_REACHABILITY_PROBE_TIMEOUT_MS, stampWriter = writeCoreSourceStamp, restoreOnRsyncFailure = false, backupCapacityStatfsSync = fs.statfsSync, backupCapacityCloneProbe = probeCopyOnWriteClone, backupCapacityStatSync = fs.statSync) {
1527
1540
  const hqRoot = resolveHqRoot(cfg, err);
1528
1541
  const RUN_TS = utcStamp(new Date(), "dash");
1529
1542
  // Both the release overlay and a restorable snapshot require rsync. A shim
@@ -2038,103 +2051,110 @@ function doRescue(cfg, env, out, err, setTmp, retainTmp, gitReachabilityProbeTim
2038
2051
  }
2039
2052
  }
2040
2053
  }
2041
- requireBackupCapacity(cfg, hqRoot, snapshotRoots, env, err, backupCapacityStatfsSync);
2042
- fs.mkdirSync(cfg.backupRoot, { recursive: true });
2043
- const runId = `${process.pid}-${Date.now().toString(36)}`;
2044
- backupDir = allocateSnapshotDir(cfg.backupRoot, RUN_TS, canonicalRootHash(hqRoot), runId);
2045
- out("\n");
2046
- out(`==> Safety snapshot -> ${backupDir}\n`);
2047
- const snapshotSymlinks = [];
2048
- const snapshotSkippedErrors = [];
2049
- try {
2050
- fs.mkdirSync(backupDir, { recursive: false });
2051
- writeSnapshotManifest(backupDir, snapshotManifest("copying", hqRoot, runId, cfg, srcSha, snapshotRoots));
2052
- const attemptedRoots = snapshotRoots.filter((rootRel) => !isRescueTransientDirectory(rootRel));
2053
- const failedRoots = [];
2054
- for (const rootRel of snapshotRoots) {
2055
- const srcAbs = path.join(hqRoot, rootRel);
2056
- if (isRescueTransientDirectory(rootRel))
2057
- continue;
2058
- try {
2059
- const copy = cpAToFiltered(srcAbs, path.join(backupDir, rootRel), rootRel, hqRoot, snapshotSymlinks, snapshotSkippedErrors);
2060
- if (!copy.captured) {
2061
- const failure = copy.firstFailure ??
2062
- new SnapshotCopyError(rootRel, "metadata", new Error("snapshot root contains no capturable entries"), srcAbs);
2063
- if (!copy.firstFailure)
2064
- snapshotSkippedErrors.push(failure);
2065
- failedRoots.push(failure);
2054
+ const snapshotCapacityAvailable = requireBackupCapacity(cfg, hqRoot, snapshotRoots, srcDir, env, err, backupCapacityStatfsSync, backupCapacityCloneProbe, backupCapacityStatSync);
2055
+ if (!snapshotCapacityAvailable) {
2056
+ cfg.doBackup = false;
2057
+ out("\nwarning: safety snapshot skipped because available space cannot cover the snapshot allowance; the cloud update continues without a pre-update restore point.\n");
2058
+ err("HQ_RESCUE_SNAPSHOT=skipped-low-space\n");
2059
+ }
2060
+ if (snapshotCapacityAvailable) {
2061
+ fs.mkdirSync(cfg.backupRoot, { recursive: true });
2062
+ const runId = `${process.pid}-${Date.now().toString(36)}`;
2063
+ backupDir = allocateSnapshotDir(cfg.backupRoot, RUN_TS, canonicalRootHash(hqRoot), runId);
2064
+ out("\n");
2065
+ out(`==> Safety snapshot -> ${backupDir}\n`);
2066
+ const snapshotSymlinks = [];
2067
+ const snapshotSkippedErrors = [];
2068
+ try {
2069
+ fs.mkdirSync(backupDir, { recursive: false });
2070
+ writeSnapshotManifest(backupDir, snapshotManifest("copying", hqRoot, runId, cfg, srcSha, snapshotRoots));
2071
+ const attemptedRoots = snapshotRoots.filter((rootRel) => !isRescueTransientDirectory(rootRel));
2072
+ const failedRoots = [];
2073
+ for (const rootRel of snapshotRoots) {
2074
+ const srcAbs = path.join(hqRoot, rootRel);
2075
+ if (isRescueTransientDirectory(rootRel))
2076
+ continue;
2077
+ try {
2078
+ const copy = cpAToFiltered(srcAbs, path.join(backupDir, rootRel), rootRel, hqRoot, snapshotSymlinks, snapshotSkippedErrors);
2079
+ if (!copy.captured) {
2080
+ const failure = copy.firstFailure ??
2081
+ new SnapshotCopyError(rootRel, "metadata", new Error("snapshot root contains no capturable entries"), srcAbs);
2082
+ if (!copy.firstFailure)
2083
+ snapshotSkippedErrors.push(failure);
2084
+ failedRoots.push(failure);
2085
+ }
2086
+ }
2087
+ catch (error) {
2088
+ if (error instanceof SnapshotCopyError) {
2089
+ snapshotSkippedErrors.push(error);
2090
+ failedRoots.push(error);
2091
+ if (error.code === "ENOSPC")
2092
+ break;
2093
+ continue;
2094
+ }
2095
+ throw error;
2066
2096
  }
2067
2097
  }
2068
- catch (error) {
2069
- if (error instanceof SnapshotCopyError) {
2070
- snapshotSkippedErrors.push(error);
2071
- failedRoots.push(error);
2072
- if (error.code === "ENOSPC")
2073
- break;
2074
- continue;
2098
+ snapshotSkippedEntries = snapshotSkippedErrors.map(snapshotCopyFailure);
2099
+ const diskFull = snapshotSkippedErrors.find((error) => error.code === "ENOSPC");
2100
+ if (diskFull) {
2101
+ reportSnapshotCopyFailure(diskFull, err);
2102
+ try {
2103
+ updateSnapshotStatus(backupDir, "failed-pre-mutation", undefined, {
2104
+ symlinks: snapshotSymlinks,
2105
+ copyFailure: snapshotCopyFailure(diskFull),
2106
+ skippedEntries: snapshotSkippedEntries,
2107
+ });
2075
2108
  }
2076
- throw error;
2109
+ catch (error) {
2110
+ err(`warning: could not record the failed safety snapshot while disk space is exhausted (${error instanceof Error ? error.message : String(error)}).\n`);
2111
+ err("HQ_RESCUE_MANIFEST_UPDATE_FAILED=1\n");
2112
+ }
2113
+ return { status: 1 };
2077
2114
  }
2078
- }
2079
- snapshotSkippedEntries = snapshotSkippedErrors.map(snapshotCopyFailure);
2080
- const diskFull = snapshotSkippedErrors.find((error) => error.code === "ENOSPC");
2081
- if (diskFull) {
2082
- reportSnapshotCopyFailure(diskFull, err);
2083
- try {
2115
+ if (attemptedRoots.length > 0 &&
2116
+ failedRoots.length === attemptedRoots.length) {
2117
+ const failure = failedRoots[0];
2084
2118
  updateSnapshotStatus(backupDir, "failed-pre-mutation", undefined, {
2085
2119
  symlinks: snapshotSymlinks,
2086
- copyFailure: snapshotCopyFailure(diskFull),
2120
+ copyFailure: snapshotCopyFailure(failure),
2087
2121
  skippedEntries: snapshotSkippedEntries,
2088
2122
  });
2123
+ reportSnapshotCopyFailure(failure, err);
2124
+ throw new ExitError(1);
2089
2125
  }
2090
- catch (error) {
2091
- err(`warning: could not record the failed safety snapshot while disk space is exhausted (${error instanceof Error ? error.message : String(error)}).\n`);
2092
- err("HQ_RESCUE_MANIFEST_UPDATE_FAILED=1\n");
2126
+ for (const error of snapshotSkippedErrors) {
2127
+ reportSkippedSnapshotCopy(error, err);
2093
2128
  }
2094
- return { status: 1 };
2095
- }
2096
- if (attemptedRoots.length > 0 &&
2097
- failedRoots.length === attemptedRoots.length) {
2098
- const failure = failedRoots[0];
2099
- updateSnapshotStatus(backupDir, "failed-pre-mutation", undefined, {
2129
+ protectSkippedSnapshotPaths(ctx, snapshotSkippedEntries);
2130
+ updateSnapshotStatus(backupDir, snapshotSkippedEntries.length > 0 ? "incomplete" : "ready", undefined, {
2100
2131
  symlinks: snapshotSymlinks,
2101
- copyFailure: snapshotCopyFailure(failure),
2102
2132
  skippedEntries: snapshotSkippedEntries,
2103
2133
  });
2104
- reportSnapshotCopyFailure(failure, err);
2105
- throw new ExitError(1);
2106
2134
  }
2107
- for (const error of snapshotSkippedErrors) {
2108
- reportSkippedSnapshotCopy(error, err);
2109
- }
2110
- protectSkippedSnapshotPaths(ctx, snapshotSkippedEntries);
2111
- updateSnapshotStatus(backupDir, snapshotSkippedEntries.length > 0 ? "incomplete" : "ready", undefined, {
2112
- symlinks: snapshotSymlinks,
2113
- skippedEntries: snapshotSkippedEntries,
2114
- });
2115
- }
2116
- catch (error) {
2117
- if (error instanceof SnapshotCopyError) {
2135
+ catch (error) {
2136
+ if (error instanceof SnapshotCopyError) {
2137
+ updateSnapshotStatus(backupDir, "failed-pre-mutation", undefined, {
2138
+ symlinks: snapshotSymlinks,
2139
+ copyFailure: snapshotCopyFailure(error),
2140
+ skippedEntries: snapshotSkippedEntries,
2141
+ });
2142
+ reportSnapshotCopyFailure(error, err);
2143
+ throw new ExitError(1);
2144
+ }
2118
2145
  updateSnapshotStatus(backupDir, "failed-pre-mutation", undefined, {
2119
2146
  symlinks: snapshotSymlinks,
2120
- copyFailure: snapshotCopyFailure(error),
2121
2147
  skippedEntries: snapshotSkippedEntries,
2122
2148
  });
2123
- reportSnapshotCopyFailure(error, err);
2124
- throw new ExitError(1);
2149
+ throw error;
2150
+ }
2151
+ pruneManagedBackups(cfg, hqRoot, out, true);
2152
+ if (snapshotSkippedEntries.length > 0) {
2153
+ out(" snapshot incomplete: skipped files were left untouched and this snapshot cannot restore every captured root.\n");
2154
+ }
2155
+ else {
2156
+ out(" snapshot ready (complete recovery is available through --recover-snapshots).\n");
2125
2157
  }
2126
- updateSnapshotStatus(backupDir, "failed-pre-mutation", undefined, {
2127
- symlinks: snapshotSymlinks,
2128
- skippedEntries: snapshotSkippedEntries,
2129
- });
2130
- throw error;
2131
- }
2132
- pruneManagedBackups(cfg, hqRoot, out, true);
2133
- if (snapshotSkippedEntries.length > 0) {
2134
- out(" snapshot incomplete: skipped files were left untouched and this snapshot cannot restore every captured root.\n");
2135
- }
2136
- else {
2137
- out(" snapshot ready (complete recovery is available through --recover-snapshots).\n");
2138
2158
  }
2139
2159
  }
2140
2160
  else if (cfg.doBackup &&
@@ -4526,43 +4546,145 @@ function estimateShuttleBytes(abs) {
4526
4546
  .readdirSync(abs)
4527
4547
  .reduce((total, name) => total + estimateShuttleBytes(path.join(abs, name)), 0);
4528
4548
  }
4529
- export function requireBackupCapacity(cfg, hqRoot, roots, _env, err, statfsSync = fs.statfsSync) {
4530
- let failure;
4549
+ export function requireBackupCapacity(cfg, hqRoot, roots, srcDir, _env, err, statfsSync = fs.statfsSync, cloneProbe = probeCopyOnWriteClone, statSync = fs.statSync) {
4531
4550
  try {
4532
4551
  const estimate = roots.reduce((total, root) => total + estimateFiltered(path.join(hqRoot, root), root).bytes, 0);
4533
- const backupPath = nearestExistingDirectory(cfg.backupRoot);
4534
- const tempPath = nearestExistingDirectory(os.tmpdir());
4535
- const backupFs = statfsSync(backupPath);
4552
+ const backupLocation = nearestExistingDirectory(cfg.backupRoot);
4553
+ const tempLocation = nearestExistingDirectory(os.tmpdir());
4554
+ const hqDevice = Number(statSync(hqRoot).dev);
4555
+ const backupDevice = Number(statSync(backupLocation).dev);
4556
+ const tempDevice = Number(statSync(tempLocation).dev);
4557
+ const backupFs = statfsSync(backupLocation);
4536
4558
  const backupFree = Number(backupFs.bavail) * Number(backupFs.bsize);
4537
4559
  const backupCapacity = Number(backupFs.blocks) * Number(backupFs.bsize);
4538
- const backupMargin = Math.min(10 * 1024 ** 3, Math.max(5 * 1024 ** 3, Math.ceil(backupCapacity * 0.1)));
4560
+ const sameHqBackupFilesystem = hqDevice === backupDevice;
4561
+ const sameBackupTempFilesystem = backupDevice === tempDevice;
4562
+ const sameHqTempFilesystem = hqDevice === tempDevice;
4539
4563
  const shuttleBytes = (cfg.preserveSubpaths ?? []).reduce((total, rel) => total + estimateShuttleBytes(path.join(hqRoot, rel)), 0);
4540
- const sameFilesystem = fs.statSync(backupPath).dev === fs.statSync(tempPath).dev;
4541
- const backupRequired = estimate + backupMargin + (sameFilesystem ? shuttleBytes : 0);
4542
- if (backupFree < backupRequired) {
4543
- failure = { required: backupRequired, free: backupFree };
4544
- }
4545
- else if (!sameFilesystem && shuttleBytes > 0) {
4546
- const tempFs = statfsSync(tempPath);
4547
- const tempFree = Number(tempFs.bavail) * Number(tempFs.bsize);
4564
+ const snapshotCloneSupported = sameHqBackupFilesystem && cloneProbe(hqRoot, cfg.backupRoot, roots);
4565
+ const shuttleCloneSupported = shuttleBytes > 0 &&
4566
+ sameHqTempFilesystem &&
4567
+ (sameBackupTempFilesystem && snapshotCloneSupported
4568
+ ? true
4569
+ : cloneProbe(hqRoot, tempLocation, cfg.preserveSubpaths ?? []));
4570
+ const backupMargin = Math.min(10 * 1024 ** 3, Math.max(5 * 1024 ** 3, Math.ceil(backupCapacity * 0.1)));
4571
+ const snapshotRequired = snapshotCloneSupported
4572
+ ? SNAPSHOT_CLONE_METADATA_ALLOWANCE_BYTES
4573
+ : estimate + backupMargin;
4574
+ const shuttleOnBackupRequired = sameBackupTempFilesystem
4575
+ ? shuttleBytes === 0
4576
+ ? 0
4577
+ : shuttleCloneSupported
4578
+ ? SNAPSHOT_CLONE_METADATA_ALLOWANCE_BYTES
4579
+ : shuttleBytes
4580
+ : 0;
4581
+ const backupRequired = snapshotRequired + shuttleOnBackupRequired;
4582
+ let shuttleFree = Number.POSITIVE_INFINITY;
4583
+ let shuttleRequired = 0;
4584
+ if (!sameBackupTempFilesystem && shuttleBytes > 0) {
4585
+ const tempFs = statfsSync(tempLocation);
4586
+ shuttleFree = Number(tempFs.bavail) * Number(tempFs.bsize);
4548
4587
  const tempCapacity = Number(tempFs.blocks) * Number(tempFs.bsize);
4549
4588
  const tempMargin = Math.min(10 * 1024 ** 3, Math.max(5 * 1024 ** 3, Math.ceil(tempCapacity * 0.1)));
4550
- const tempRequired = shuttleBytes + tempMargin;
4551
- if (tempFree < tempRequired) {
4552
- failure = { required: tempRequired, free: tempFree };
4589
+ shuttleRequired = shuttleCloneSupported
4590
+ ? SNAPSHOT_CLONE_METADATA_ALLOWANCE_BYTES
4591
+ : shuttleBytes + tempMargin;
4592
+ }
4593
+ const backupFits = backupFree >= backupRequired;
4594
+ const shuttleFits = shuttleFree >= shuttleRequired;
4595
+ if (backupFits && shuttleFits)
4596
+ return true;
4597
+ if (!shuttleFits) {
4598
+ err(`error: insufficient free space for safety snapshot (need ${shuttleRequired} bytes, have ${shuttleFree}).\n`);
4599
+ err("HQ_RESCUE_FAILURE_KIND=disk_full\n");
4600
+ throw new ExitError(1);
4601
+ }
4602
+ if (cfg.cloudUpdate) {
4603
+ const overlayEstimate = estimateFiltered(srcDir, "").bytes;
4604
+ const overlayRequired = overlayEstimate + CLOUD_UPDATE_OVERLAY_FREE_SPACE_FLOOR_BYTES;
4605
+ const hqFs = hqDevice === backupDevice ? backupFs : statfsSync(hqRoot);
4606
+ const hqFree = Number(hqFs.bavail) * Number(hqFs.bsize);
4607
+ const overlayOnBackupFs = hqDevice === backupDevice;
4608
+ const overlayNeed = overlayRequired +
4609
+ (overlayOnBackupFs ? shuttleOnBackupRequired : 0);
4610
+ const separateShuttleFits = overlayOnBackupFs || backupFree >= shuttleOnBackupRequired;
4611
+ if (hqFree >= overlayNeed && separateShuttleFits) {
4612
+ return false;
4553
4613
  }
4614
+ err(`error: insufficient free space for safety snapshot (need ${overlayRequired} bytes, have ${hqFree}).\n`);
4615
+ err("HQ_RESCUE_FAILURE_KIND=disk_full\n");
4616
+ throw new ExitError(1);
4554
4617
  }
4618
+ err(`error: insufficient free space for safety snapshot (need ${backupRequired} bytes, have ${backupFree}).\n`);
4619
+ err("HQ_RESCUE_FAILURE_KIND=disk_full\n");
4620
+ throw new ExitError(1);
4555
4621
  }
4556
4622
  catch (error) {
4623
+ if (error instanceof ExitError)
4624
+ throw error;
4557
4625
  err(`error: could not measure snapshot size/free space (${error instanceof Error ? error.message : String(error)}).\n`);
4558
4626
  err("HQ_RESCUE_FAILURE_KIND=backup-capacity-check-failed\n");
4559
4627
  throw new ExitError(1);
4560
4628
  }
4561
- if (!failure)
4562
- return;
4563
- err(`error: insufficient free space for safety snapshot (need ${failure.required} bytes, have ${failure.free}).\n`);
4564
- err("HQ_RESCUE_FAILURE_KIND=disk_full\n");
4565
- throw new ExitError(1);
4629
+ return true;
4630
+ }
4631
+ function probeCopyOnWriteClone(hqRoot, backupRoot, roots) {
4632
+ const source = roots
4633
+ .map((root) => firstRegularFile(path.join(hqRoot, root)))
4634
+ .find((candidate) => candidate !== null);
4635
+ if (!source)
4636
+ return false;
4637
+ const parent = nearestExistingDirectory(backupRoot);
4638
+ let probeDir;
4639
+ try {
4640
+ probeDir = fs.mkdtempSync(path.join(parent, ".hq-rescue-clone-probe-"));
4641
+ }
4642
+ catch (error) {
4643
+ if (error.code === "ENOSPC")
4644
+ return false;
4645
+ throw error;
4646
+ }
4647
+ try {
4648
+ fs.copyFileSync(source, path.join(probeDir, "probe"), fs.constants.COPYFILE_FICLONE_FORCE);
4649
+ return true;
4650
+ }
4651
+ catch (error) {
4652
+ const code = error.code;
4653
+ if (code === "EXDEV" ||
4654
+ code === "EINVAL" ||
4655
+ code === "ENOSYS" ||
4656
+ code === "ENOTSUP" ||
4657
+ code === "EOPNOTSUPP" ||
4658
+ code === "ENOTTY" ||
4659
+ code === "ENOSPC" ||
4660
+ code === "EPERM") {
4661
+ return false;
4662
+ }
4663
+ throw error;
4664
+ }
4665
+ finally {
4666
+ fs.rmSync(probeDir, { recursive: true, force: true });
4667
+ }
4668
+ }
4669
+ function firstRegularFile(root, rel = "") {
4670
+ const st = lstatOrNull(root);
4671
+ if (!st || st.isSymbolicLink())
4672
+ return null;
4673
+ if (rel && isRescueTransientDirectory(rel))
4674
+ return null;
4675
+ if (st.isFile())
4676
+ return root;
4677
+ if (!st.isDirectory())
4678
+ return null;
4679
+ for (const name of fs.readdirSync(root)) {
4680
+ const childRel = path.posix.join(rel, name);
4681
+ if (isRescueTransientDirectory(childRel))
4682
+ continue;
4683
+ const found = firstRegularFile(path.join(root, name), childRel);
4684
+ if (found)
4685
+ return found;
4686
+ }
4687
+ return null;
4566
4688
  }
4567
4689
  function nearestExistingDirectory(target) {
4568
4690
  let current = path.resolve(target);
@@ -4641,7 +4763,7 @@ function copySymlinkWithWindowsFallback(src, dest, link) {
4641
4763
  const targetStats = fs.statSync(src);
4642
4764
  if (targetStats.isFile()) {
4643
4765
  fs.mkdirSync(path.dirname(dest), { recursive: true });
4644
- fs.copyFileSync(src, dest);
4766
+ fs.copyFileSync(src, dest, fs.constants.COPYFILE_FICLONE);
4645
4767
  preserveSnapshotMetadata(dest, targetStats);
4646
4768
  return;
4647
4769
  }
@@ -4681,7 +4803,10 @@ function cpATo(src, dest) {
4681
4803
  if (destinationStats && !destinationStats.isFile()) {
4682
4804
  fs.rmSync(dest, { recursive: true, force: true });
4683
4805
  }
4684
- fs.copyFileSync(src, dest);
4806
+ // The shuttle is on the same filesystem as the HQ root on common installs,
4807
+ // so preserve it with a clone where available. Node falls back to a full
4808
+ // copy on filesystems without reflink support.
4809
+ fs.copyFileSync(src, dest, fs.constants.COPYFILE_FICLONE);
4685
4810
  try {
4686
4811
  fs.chmodSync(dest, st.mode);
4687
4812
  fs.utimesSync(dest, st.atime, st.mtime);
@@ -5132,55 +5257,37 @@ function validateSnapshotSymlink(hqRoot, rel, target) {
5132
5257
  ? null
5133
5258
  : `target for ${rel} resolves outside the HQ root`;
5134
5259
  }
5135
- function copyRegularFileForSnapshot(src, dest, rel, mode) {
5136
- let sourceFd;
5137
- let destinationFd;
5260
+ function copyRegularFileForSnapshot(src, dest, rel) {
5138
5261
  try {
5139
- try {
5140
- sourceFd = fs.openSync(src, "r");
5141
- }
5142
- catch (error) {
5143
- throw new SnapshotCopyError(rel, "read", error, src);
5144
- }
5145
- try {
5146
- destinationFd = fs.openSync(dest, "w", mode);
5147
- }
5148
- catch (error) {
5149
- throw new SnapshotCopyError(rel, "write", error, dest);
5150
- }
5151
- const buffer = Buffer.allocUnsafe(64 * 1024);
5152
- for (;;) {
5153
- let read;
5262
+ // Node falls back to a full copy when the filesystem cannot reflink.
5263
+ // Restore paths use ordinary copies, so they remain independent files.
5264
+ fs.copyFileSync(src, dest, fs.constants.COPYFILE_FICLONE);
5265
+ }
5266
+ catch (error) {
5267
+ if (error.code === "EDEADLK") {
5154
5268
  try {
5155
- read = fs.readSync(sourceFd, buffer, 0, buffer.length, null);
5156
- }
5157
- catch (error) {
5158
- throw new SnapshotCopyError(rel, "read", error, src);
5269
+ fs.copyFileSync(src, dest);
5270
+ return;
5159
5271
  }
5160
- if (read === 0)
5161
- break;
5162
- let offset = 0;
5163
- while (offset < read) {
5164
- let written;
5165
- try {
5166
- written = fs.writeSync(destinationFd, buffer, offset, read - offset);
5167
- }
5168
- catch (error) {
5169
- throw new SnapshotCopyError(rel, "write", error, dest);
5170
- }
5171
- if (written === 0) {
5172
- throw new SnapshotCopyError(rel, "write", new Error("write returned zero bytes"));
5173
- }
5174
- offset += written;
5272
+ catch (fallbackError) {
5273
+ throw classifySnapshotCopyFailure(rel, src, dest, fallbackError);
5175
5274
  }
5176
5275
  }
5276
+ throw classifySnapshotCopyFailure(rel, src, dest, error);
5177
5277
  }
5178
- finally {
5179
- if (destinationFd !== undefined)
5180
- fs.closeSync(destinationFd);
5181
- if (sourceFd !== undefined)
5182
- fs.closeSync(sourceFd);
5278
+ }
5279
+ function classifySnapshotCopyFailure(rel, src, dest, error) {
5280
+ if (error.code === "EDEADLK") {
5281
+ return new SnapshotCopyError(rel, "read", error, src);
5282
+ }
5283
+ try {
5284
+ const sourceFd = fs.openSync(src, "r");
5285
+ fs.closeSync(sourceFd);
5286
+ }
5287
+ catch (sourceError) {
5288
+ return new SnapshotCopyError(rel, "read", sourceError, src);
5183
5289
  }
5290
+ return new SnapshotCopyError(rel, "write", error, dest);
5184
5291
  }
5185
5292
  /** Preserve a Windows file link as ordinary file data when links are forbidden. */
5186
5293
  function copySymlinkFileRepresentationForSnapshot(src, dest, rel) {
@@ -5200,7 +5307,7 @@ function copySymlinkFileRepresentationForSnapshot(src, dest, rel) {
5200
5307
  catch (error) {
5201
5308
  throw new SnapshotCopyError(rel, "write", error, dest);
5202
5309
  }
5203
- copyRegularFileForSnapshot(src, dest, rel, targetStats.mode);
5310
+ copyRegularFileForSnapshot(src, dest, rel);
5204
5311
  preserveSnapshotMetadata(dest, targetStats);
5205
5312
  }
5206
5313
  /** Snapshot copy that preserves metadata while pruning canonical transient directories. */
@@ -5285,7 +5392,7 @@ function cpAToFiltered(src, dest, rel, hqRoot, symlinks, skippedEntries) {
5285
5392
  catch (error) {
5286
5393
  throw new SnapshotCopyError(rel, "write", error, dest);
5287
5394
  }
5288
- copyRegularFileForSnapshot(src, dest, rel, st.mode);
5395
+ copyRegularFileForSnapshot(src, dest, rel);
5289
5396
  try {
5290
5397
  fs.chmodSync(dest, st.mode);
5291
5398
  fs.utimesSync(dest, st.atime, st.mtime);