@indigoai-us/hq-cloud 6.18.26 → 6.18.28

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 (47) hide show
  1. package/dist/cli/core-source-stamp.d.ts +0 -5
  2. package/dist/cli/core-source-stamp.d.ts.map +1 -1
  3. package/dist/cli/core-source-stamp.js +1 -1
  4. package/dist/cli/core-source-stamp.js.map +1 -1
  5. package/dist/cli/rescue-breaker.test.js +277 -3
  6. package/dist/cli/rescue-breaker.test.js.map +1 -1
  7. package/dist/cli/rescue-core.d.ts +6 -0
  8. package/dist/cli/rescue-core.d.ts.map +1 -1
  9. package/dist/cli/rescue-core.js +293 -138
  10. package/dist/cli/rescue-core.js.map +1 -1
  11. package/dist/cli/rescue-preserve-copy-symlink.test.js +3 -8
  12. package/dist/cli/rescue-preserve-copy-symlink.test.js.map +1 -1
  13. package/dist/cli/rescue-rsync-status.test.js +28 -2
  14. package/dist/cli/rescue-rsync-status.test.js.map +1 -1
  15. package/dist/cli/rescue-snapshot-copy.test.js +147 -41
  16. package/dist/cli/rescue-snapshot-copy.test.js.map +1 -1
  17. package/dist/cognito-auth.d.ts +5 -1
  18. package/dist/cognito-auth.d.ts.map +1 -1
  19. package/dist/cognito-auth.js +40 -6
  20. package/dist/cognito-auth.js.map +1 -1
  21. package/dist/cognito-auth.test.js +45 -0
  22. package/dist/cognito-auth.test.js.map +1 -1
  23. package/dist/outposts/session-host-auth.d.ts +3 -2
  24. package/dist/outposts/session-host-auth.d.ts.map +1 -1
  25. package/dist/outposts/session-host-fakes.d.ts +1 -1
  26. package/dist/outposts/session-host-fakes.d.ts.map +1 -1
  27. package/dist/outposts/session-host-fakes.js +1 -1
  28. package/dist/outposts/session-host-fakes.js.map +1 -1
  29. package/dist/outposts/session-host-protocol.d.ts +11 -12
  30. package/dist/outposts/session-host-protocol.d.ts.map +1 -1
  31. package/dist/outposts/session-host-protocol.js +1 -1
  32. package/dist/outposts/session-host-protocol.js.map +1 -1
  33. package/dist/outposts/session-host-sessions.d.ts +2 -1
  34. package/dist/outposts/session-host-sessions.d.ts.map +1 -1
  35. package/dist/session-log-retention.d.ts +0 -10
  36. package/dist/session-log-retention.d.ts.map +1 -1
  37. package/dist/session-log-retention.js +1 -1
  38. package/dist/session-log-retention.js.map +1 -1
  39. package/dist/signals/internals.d.ts +0 -6
  40. package/dist/signals/internals.d.ts.map +1 -1
  41. package/dist/signals/internals.js +3 -14
  42. package/dist/signals/internals.js.map +1 -1
  43. package/dist/sources/internals.d.ts +0 -4
  44. package/dist/sources/internals.d.ts.map +1 -1
  45. package/dist/sources/internals.js +3 -3
  46. package/dist/sources/internals.js.map +1 -1
  47. 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 &&
@@ -4498,23 +4518,173 @@ function estimateFiltered(abs, rel) {
4498
4518
  }
4499
4519
  return { files, bytes };
4500
4520
  }
4501
- function requireBackupCapacity(cfg, hqRoot, roots, _env, err, statfsSync = fs.statfsSync) {
4521
+ function estimateShuttleBytes(abs) {
4522
+ const st = lstatOrNull(abs);
4523
+ if (!st)
4524
+ return 0;
4525
+ if (st.isSymbolicLink()) {
4526
+ const linkBytes = Buffer.byteLength(fs.readlinkSync(abs));
4527
+ if (process.platform === "win32") {
4528
+ try {
4529
+ const target = fs.statSync(abs);
4530
+ if (target.isFile())
4531
+ return Math.max(linkBytes, target.size);
4532
+ }
4533
+ catch (error) {
4534
+ const code = error.code;
4535
+ if (code !== "ENOENT" && code !== "ENOTDIR")
4536
+ throw error;
4537
+ }
4538
+ }
4539
+ return linkBytes;
4540
+ }
4541
+ if (st.isFile())
4542
+ return st.size;
4543
+ if (!st.isDirectory())
4544
+ return 0;
4545
+ return fs
4546
+ .readdirSync(abs)
4547
+ .reduce((total, name) => total + estimateShuttleBytes(path.join(abs, name)), 0);
4548
+ }
4549
+ export function requireBackupCapacity(cfg, hqRoot, roots, srcDir, _env, err, statfsSync = fs.statfsSync, cloneProbe = probeCopyOnWriteClone, statSync = fs.statSync) {
4502
4550
  try {
4503
4551
  const estimate = roots.reduce((total, root) => total + estimateFiltered(path.join(hqRoot, root), root).bytes, 0);
4504
- const stat = statfsSync(nearestExistingDirectory(cfg.backupRoot));
4505
- const free = Number(stat.bavail) * Number(stat.bsize);
4506
- const capacity = Number(stat.blocks) * Number(stat.bsize);
4507
- const required = estimate + Math.max(5 * 1024 ** 3, Math.ceil(capacity * 0.1));
4508
- if (free >= required)
4509
- return;
4510
- err(`error: insufficient free space for safety snapshot (need ${required} bytes, have ${free}).\n`);
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);
4558
+ const backupFree = Number(backupFs.bavail) * Number(backupFs.bsize);
4559
+ const backupCapacity = Number(backupFs.blocks) * Number(backupFs.bsize);
4560
+ const sameHqBackupFilesystem = hqDevice === backupDevice;
4561
+ const sameBackupTempFilesystem = backupDevice === tempDevice;
4562
+ const sameHqTempFilesystem = hqDevice === tempDevice;
4563
+ const shuttleBytes = (cfg.preserveSubpaths ?? []).reduce((total, rel) => total + estimateShuttleBytes(path.join(hqRoot, rel)), 0);
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);
4587
+ const tempCapacity = Number(tempFs.blocks) * Number(tempFs.bsize);
4588
+ const tempMargin = Math.min(10 * 1024 ** 3, Math.max(5 * 1024 ** 3, Math.ceil(tempCapacity * 0.1)));
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;
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);
4617
+ }
4618
+ err(`error: insufficient free space for safety snapshot (need ${backupRequired} bytes, have ${backupFree}).\n`);
4511
4619
  err("HQ_RESCUE_FAILURE_KIND=disk_full\n");
4620
+ throw new ExitError(1);
4512
4621
  }
4513
4622
  catch (error) {
4623
+ if (error instanceof ExitError)
4624
+ throw error;
4514
4625
  err(`error: could not measure snapshot size/free space (${error instanceof Error ? error.message : String(error)}).\n`);
4515
4626
  err("HQ_RESCUE_FAILURE_KIND=backup-capacity-check-failed\n");
4627
+ throw new ExitError(1);
4628
+ }
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;
4516
4686
  }
4517
- throw new ExitError(1);
4687
+ return null;
4518
4688
  }
4519
4689
  function nearestExistingDirectory(target) {
4520
4690
  let current = path.resolve(target);
@@ -4593,7 +4763,7 @@ function copySymlinkWithWindowsFallback(src, dest, link) {
4593
4763
  const targetStats = fs.statSync(src);
4594
4764
  if (targetStats.isFile()) {
4595
4765
  fs.mkdirSync(path.dirname(dest), { recursive: true });
4596
- fs.copyFileSync(src, dest);
4766
+ fs.copyFileSync(src, dest, fs.constants.COPYFILE_FICLONE);
4597
4767
  preserveSnapshotMetadata(dest, targetStats);
4598
4768
  return;
4599
4769
  }
@@ -4633,7 +4803,10 @@ function cpATo(src, dest) {
4633
4803
  if (destinationStats && !destinationStats.isFile()) {
4634
4804
  fs.rmSync(dest, { recursive: true, force: true });
4635
4805
  }
4636
- 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);
4637
4810
  try {
4638
4811
  fs.chmodSync(dest, st.mode);
4639
4812
  fs.utimesSync(dest, st.atime, st.mtime);
@@ -5084,55 +5257,37 @@ function validateSnapshotSymlink(hqRoot, rel, target) {
5084
5257
  ? null
5085
5258
  : `target for ${rel} resolves outside the HQ root`;
5086
5259
  }
5087
- function copyRegularFileForSnapshot(src, dest, rel, mode) {
5088
- let sourceFd;
5089
- let destinationFd;
5260
+ function copyRegularFileForSnapshot(src, dest, rel) {
5090
5261
  try {
5091
- try {
5092
- sourceFd = fs.openSync(src, "r");
5093
- }
5094
- catch (error) {
5095
- throw new SnapshotCopyError(rel, "read", error, src);
5096
- }
5097
- try {
5098
- destinationFd = fs.openSync(dest, "w", mode);
5099
- }
5100
- catch (error) {
5101
- throw new SnapshotCopyError(rel, "write", error, dest);
5102
- }
5103
- const buffer = Buffer.allocUnsafe(64 * 1024);
5104
- for (;;) {
5105
- 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") {
5106
5268
  try {
5107
- read = fs.readSync(sourceFd, buffer, 0, buffer.length, null);
5269
+ fs.copyFileSync(src, dest);
5270
+ return;
5108
5271
  }
5109
- catch (error) {
5110
- throw new SnapshotCopyError(rel, "read", error, src);
5111
- }
5112
- if (read === 0)
5113
- break;
5114
- let offset = 0;
5115
- while (offset < read) {
5116
- let written;
5117
- try {
5118
- written = fs.writeSync(destinationFd, buffer, offset, read - offset);
5119
- }
5120
- catch (error) {
5121
- throw new SnapshotCopyError(rel, "write", error, dest);
5122
- }
5123
- if (written === 0) {
5124
- throw new SnapshotCopyError(rel, "write", new Error("write returned zero bytes"));
5125
- }
5126
- offset += written;
5272
+ catch (fallbackError) {
5273
+ throw classifySnapshotCopyFailure(rel, src, dest, fallbackError);
5127
5274
  }
5128
5275
  }
5276
+ throw classifySnapshotCopyFailure(rel, src, dest, error);
5129
5277
  }
5130
- finally {
5131
- if (destinationFd !== undefined)
5132
- fs.closeSync(destinationFd);
5133
- if (sourceFd !== undefined)
5134
- 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);
5135
5289
  }
5290
+ return new SnapshotCopyError(rel, "write", error, dest);
5136
5291
  }
5137
5292
  /** Preserve a Windows file link as ordinary file data when links are forbidden. */
5138
5293
  function copySymlinkFileRepresentationForSnapshot(src, dest, rel) {
@@ -5152,7 +5307,7 @@ function copySymlinkFileRepresentationForSnapshot(src, dest, rel) {
5152
5307
  catch (error) {
5153
5308
  throw new SnapshotCopyError(rel, "write", error, dest);
5154
5309
  }
5155
- copyRegularFileForSnapshot(src, dest, rel, targetStats.mode);
5310
+ copyRegularFileForSnapshot(src, dest, rel);
5156
5311
  preserveSnapshotMetadata(dest, targetStats);
5157
5312
  }
5158
5313
  /** Snapshot copy that preserves metadata while pruning canonical transient directories. */
@@ -5237,7 +5392,7 @@ function cpAToFiltered(src, dest, rel, hqRoot, symlinks, skippedEntries) {
5237
5392
  catch (error) {
5238
5393
  throw new SnapshotCopyError(rel, "write", error, dest);
5239
5394
  }
5240
- copyRegularFileForSnapshot(src, dest, rel, st.mode);
5395
+ copyRegularFileForSnapshot(src, dest, rel);
5241
5396
  try {
5242
5397
  fs.chmodSync(dest, st.mode);
5243
5398
  fs.utimesSync(dest, st.atime, st.mtime);