@camstack/server 1.1.38 → 1.1.39

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.
@@ -36,6 +36,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.resolveHubClosurePackageDir = resolveHubClosurePackageDir;
37
37
  exports.resolveOverlayPackageDir = resolveOverlayPackageDir;
38
38
  exports.computeDistBuildId = computeDistBuildId;
39
+ exports.swapDist = swapDist;
39
40
  exports.syncFrameworkDist = syncFrameworkDist;
40
41
  /**
41
42
  * Framework live-sync — make a `camstack deploy` of a framework package
@@ -202,17 +203,27 @@ function computeDistBuildId(distDir) {
202
203
  // ---------------------------------------------------------------------------
203
204
  // Atomic dist swap (rename-aside)
204
205
  // ---------------------------------------------------------------------------
206
+ function isCrossDeviceError(err) {
207
+ return err instanceof Error && err.code === 'EXDEV';
208
+ }
205
209
  /**
206
- * Replace `<targetPkgDir>/dist` with a copy of `sourceDistDir`, atomically.
210
+ * Replace `<targetPkgDir>/dist` with a copy of `sourceDistDir`.
211
+ *
212
+ * 1. COPY `sourceDistDir` → a staging dir INSIDE the target dir. The live
213
+ * `dist/` is untouched, so a mid-copy failure cannot corrupt it.
214
+ * 2. Evict the current `dist/` aside (rename), then rename the staging dir into
215
+ * place. Both renames are metadata ops WITHIN the target fs (atomic). If the
216
+ * final swap fails, the previous `dist/` is restored.
207
217
  *
208
- * 1. Cross-device COPY `sourceDistDir` → a staging dir INSIDE the target's
209
- * filesystem. The live `dist/` is untouched, so a mid-copy failure cannot
210
- * corrupt it.
211
- * 2. Rename the current `dist/` aside, then rename the staging dir into place.
212
- * Both renames are metadata ops WITHIN the target fs (atomic). If the final
213
- * swap fails, the previous `dist/` is restored so the target is never left
214
- * without one.
215
- * 3. Best-effort delete of the evicted copy.
218
+ * OVERLAYFS FALLBACK: the hub-closure target (`/opt/.../@camstack/system`) lives
219
+ * on the container's overlay rootfs. A `dist/` materialized from a LOWER image
220
+ * layer cannot be `rename()`d (overlayfs returns `EXDEV: cross-device link`),
221
+ * so the atomic aside/swap is impossible there. On EXDEV we fall back to a
222
+ * copy-aside restore point + in-place replace. This opens a brief non-atomic
223
+ * window, but it is safe here: the running process already holds the old
224
+ * modules in memory (nothing reads `dist/` until the scheduled restart), and
225
+ * the copy-aside is kept until the replace completes so a failure is
226
+ * recoverable.
216
227
  */
217
228
  async function swapDist(sourceDistDir, targetPkgDir) {
218
229
  const targetDist = path.join(targetPkgDir, 'dist');
@@ -227,24 +238,63 @@ async function swapDist(sourceDistDir, targetPkgDir) {
227
238
  await fs.promises.rm(incoming, { recursive: true, force: true }).catch(() => undefined);
228
239
  throw err;
229
240
  }
230
- // Step 2 — atomic swap within the target fs.
231
241
  const hadDist = fs.existsSync(targetDist);
242
+ // How the previous dist was preserved: 'rename' (moved aside, same fs),
243
+ // 'copy' (overlayfs EXDEV — copied aside then removed in place), or 'none'.
244
+ let asideKind = 'none';
245
+ const restorePreviousDist = async () => {
246
+ if (asideKind === 'none' || fs.existsSync(targetDist))
247
+ return;
248
+ if (asideKind === 'rename') {
249
+ await fs.promises.rename(aside, targetDist).catch(() => undefined);
250
+ }
251
+ else {
252
+ await fs.promises
253
+ .cp(aside, targetDist, { recursive: true, force: true })
254
+ .catch(() => undefined);
255
+ }
256
+ };
257
+ // Step 2 — evict the current dist. Prefer the atomic rename; fall back to
258
+ // copy-aside + in-place remove on overlayfs EXDEV.
232
259
  if (hadDist) {
233
- await fs.promises.rename(targetDist, aside);
260
+ try {
261
+ await fs.promises.rename(targetDist, aside);
262
+ asideKind = 'rename';
263
+ }
264
+ catch (err) {
265
+ if (!isCrossDeviceError(err)) {
266
+ await fs.promises.rm(incoming, { recursive: true, force: true }).catch(() => undefined);
267
+ throw err;
268
+ }
269
+ await fs.promises.cp(targetDist, aside, { recursive: true, force: true });
270
+ await fs.promises.rm(targetDist, { recursive: true, force: true });
271
+ asideKind = 'copy';
272
+ }
234
273
  }
274
+ // Step 3 — move the incoming dist into place; fall back to copy on EXDEV.
235
275
  try {
236
276
  await fs.promises.rename(incoming, targetDist);
237
277
  }
238
- catch (swapErr) {
239
- // Restore the previous dist so the target is never left without one.
240
- if (hadDist) {
241
- await fs.promises.rename(aside, targetDist).catch(() => undefined);
278
+ catch (err) {
279
+ if (isCrossDeviceError(err)) {
280
+ try {
281
+ await fs.promises.cp(incoming, targetDist, { recursive: true, force: true });
282
+ await fs.promises.rm(incoming, { recursive: true, force: true }).catch(() => undefined);
283
+ }
284
+ catch (copyErr) {
285
+ await restorePreviousDist();
286
+ await fs.promises.rm(incoming, { recursive: true, force: true }).catch(() => undefined);
287
+ throw copyErr;
288
+ }
289
+ }
290
+ else {
291
+ await restorePreviousDist();
292
+ await fs.promises.rm(incoming, { recursive: true, force: true }).catch(() => undefined);
293
+ throw err;
242
294
  }
243
- await fs.promises.rm(incoming, { recursive: true, force: true }).catch(() => undefined);
244
- throw swapErr;
245
295
  }
246
- // Step 3 — best-effort cleanup of the evicted copy.
247
- if (hadDist) {
296
+ // Step 4 — best-effort cleanup of the evicted copy.
297
+ if (asideKind !== 'none') {
248
298
  await fs.promises.rm(aside, { recursive: true, force: true }).catch(() => undefined);
249
299
  }
250
300
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/server",
3
- "version": "1.1.38",
3
+ "version": "1.1.39",
4
4
  "private": false,
5
5
  "files": [
6
6
  "dist",