@pygmalionjs/pygmalion 0.6.32 → 0.6.33
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.
- package/README.md +75 -2
- package/dist-lib/{FrozenRoutePreview-BwkyYvUV.js → FrozenRoutePreview-BbF5Ov5K.js} +2901 -2740
- package/dist-lib/pygmalion.js +10738 -9503
- package/dist-lib/testing.js +1 -1
- package/dist-lib/types/editor/documentSync.d.ts +15 -1
- package/dist-lib/types/editor/host.d.ts +9 -0
- package/dist-lib/types/editor/previewEnvironmentControls.d.ts +2 -0
- package/dist-lib/types/editor/revisionCatalog.d.ts +135 -0
- package/dist-lib/types/editor/revisionCatalogInstall.d.ts +62 -0
- package/dist-lib/types/editor/routePreview.d.ts +1 -1
- package/dist-lib/types/editor/store.d.ts +36 -37
- package/dist-lib/types/editor/tokens.d.ts +9 -0
- package/dist-lib/types/lib.d.ts +12 -3
- package/node/dev-mirror.mjs +564 -112
- package/node/preview-artifact-plugin.mjs +506 -68
- package/node/preview-artifact-store.mjs +358 -77
- package/node/vite.mjs +48 -2
- package/package.json +1 -1
- package/vite.d.ts +61 -4
package/node/dev-mirror.mjs
CHANGED
|
@@ -29,6 +29,8 @@ const LEASE_HEARTBEAT_MS = 2_000;
|
|
|
29
29
|
// costs a checkout and a dependency install, so the reaper errs toward keeping.
|
|
30
30
|
const DEFAULT_MIRROR_GRACE_MS = 6 * 60 * 60 * 1_000;
|
|
31
31
|
const LEASE_WAIT_TIMEOUT_MS = 180_000;
|
|
32
|
+
const MAX_INVENTORY_RUNTIME_INPUTS = 64;
|
|
33
|
+
const MAX_INVENTORY_RUNTIME_INPUT_BYTES = 16 * 1024 * 1024;
|
|
32
34
|
// A serving editor holds this for its whole session. It marks the checkout as in
|
|
33
35
|
// use for the reaper; it must never stand in the way of a sync.
|
|
34
36
|
const SERVE_LEASE_LABEL = 'serve';
|
|
@@ -37,6 +39,64 @@ export function resolveDevMirrorInventoryOutputRoot(inventory, mirrorAppRoot) {
|
|
|
37
39
|
return path.resolve(inventory?.outputRoot ?? mirrorAppRoot);
|
|
38
40
|
}
|
|
39
41
|
|
|
42
|
+
export function resolveDevMirrorInventoryArgs(inventory, context) {
|
|
43
|
+
if (typeof inventory?.args === 'function') return inventory.args(context);
|
|
44
|
+
if (inventory?.args != null) return inventory.args;
|
|
45
|
+
return [
|
|
46
|
+
inventory?.script,
|
|
47
|
+
'--source-root',
|
|
48
|
+
context.sourceRoot,
|
|
49
|
+
'--out-root',
|
|
50
|
+
context.outputRoot,
|
|
51
|
+
'--commit',
|
|
52
|
+
context.commit,
|
|
53
|
+
];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Resolves an application root only when it belongs to the same Git worktree
|
|
58
|
+
* and commit as the checkout whose identity is being leased.
|
|
59
|
+
*/
|
|
60
|
+
export async function resolveExactCheckoutSourceRoot({
|
|
61
|
+
checkoutRoot,
|
|
62
|
+
sourceRoot,
|
|
63
|
+
expectedRevision,
|
|
64
|
+
}) {
|
|
65
|
+
if (
|
|
66
|
+
typeof checkoutRoot !== 'string' ||
|
|
67
|
+
!checkoutRoot.trim() ||
|
|
68
|
+
typeof sourceRoot !== 'string' ||
|
|
69
|
+
!sourceRoot.trim() ||
|
|
70
|
+
typeof expectedRevision !== 'string' ||
|
|
71
|
+
!expectedRevision.trim()
|
|
72
|
+
) {
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
try {
|
|
76
|
+
const [checkoutTop, sourceTop, checkoutRevision, sourceRevision] =
|
|
77
|
+
await Promise.all([
|
|
78
|
+
git(checkoutRoot, 'rev-parse', '--show-toplevel'),
|
|
79
|
+
git(sourceRoot, 'rev-parse', '--show-toplevel'),
|
|
80
|
+
git(checkoutRoot, 'rev-parse', 'HEAD'),
|
|
81
|
+
git(sourceRoot, 'rev-parse', 'HEAD'),
|
|
82
|
+
]);
|
|
83
|
+
if (
|
|
84
|
+
checkoutRevision !== expectedRevision ||
|
|
85
|
+
sourceRevision !== expectedRevision
|
|
86
|
+
) {
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
const [realCheckoutTop, realSourceTop, realSourceRoot] = await Promise.all([
|
|
90
|
+
fsp.realpath(checkoutTop),
|
|
91
|
+
fsp.realpath(sourceTop),
|
|
92
|
+
fsp.realpath(sourceRoot),
|
|
93
|
+
]);
|
|
94
|
+
return realCheckoutTop === realSourceTop ? realSourceRoot : null;
|
|
95
|
+
} catch {
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
40
100
|
/** A running preview belongs to one immutable app root for its whole process. */
|
|
41
101
|
export function devPreviewNeedsRestart({
|
|
42
102
|
force = false,
|
|
@@ -357,28 +417,27 @@ export async function reapDevMirrorWorktrees({
|
|
|
357
417
|
keep = [],
|
|
358
418
|
graceMs = DEFAULT_MIRROR_GRACE_MS,
|
|
359
419
|
now = Date.now(),
|
|
420
|
+
removeWorktree = (candidate) =>
|
|
421
|
+
git(repoRoot, 'worktree', 'remove', '--force', candidate),
|
|
360
422
|
}) {
|
|
361
423
|
// git reports worktrees by their real path, so a configured path that crosses a
|
|
362
424
|
// symlink (macOS `/tmp` and `/var` both do) would never match by string alone.
|
|
363
425
|
// The base itself is a naming prefix, not necessarily a directory that exists,
|
|
364
426
|
// so fall back to resolving it inside its parent — otherwise the prefix stays
|
|
365
427
|
// in `/var` while every listed worktree comes back as `/private/var`.
|
|
366
|
-
const
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
const parent = await fsp.realpath(path.dirname(resolved)).catch(() => null);
|
|
371
|
-
return parent ? path.join(parent, path.basename(resolved)) : resolved;
|
|
372
|
-
};
|
|
373
|
-
const base = await real(mirrorBaseRoot);
|
|
374
|
-
const kept = new Set(await Promise.all(keep.filter(Boolean).map(real)));
|
|
428
|
+
const base = await canonicalDevMirrorPath(mirrorBaseRoot);
|
|
429
|
+
const kept = new Set(
|
|
430
|
+
await Promise.all(keep.filter(Boolean).map(canonicalDevMirrorPath)),
|
|
431
|
+
);
|
|
375
432
|
const raw = await git(repoRoot, 'worktree', 'list', '--porcelain').catch(() => '');
|
|
376
433
|
const candidates = (
|
|
377
434
|
await Promise.all(
|
|
378
435
|
raw
|
|
379
436
|
.split('\n')
|
|
380
437
|
.filter((line) => line.startsWith('worktree '))
|
|
381
|
-
.map((line) =>
|
|
438
|
+
.map((line) =>
|
|
439
|
+
canonicalDevMirrorPath(line.slice('worktree '.length).trim()),
|
|
440
|
+
),
|
|
382
441
|
)
|
|
383
442
|
)
|
|
384
443
|
// Only this project's mirrors. A sibling directory that merely starts with
|
|
@@ -389,26 +448,38 @@ export async function reapDevMirrorWorktrees({
|
|
|
389
448
|
const removed = [];
|
|
390
449
|
const skipped = [];
|
|
391
450
|
for (const candidate of candidates) {
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
path: candidate,
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
451
|
+
await withDevMirrorSyncLock(repoRoot, async () => {
|
|
452
|
+
// Exact source lease acquisition uses this same lock. Keeping the final
|
|
453
|
+
// lease check and removal in one critical section prevents a reader from
|
|
454
|
+
// appearing after the check but before `git worktree remove`.
|
|
455
|
+
const leases = await freshLeases(repoRoot, candidate);
|
|
456
|
+
if (leases.length > 0) {
|
|
457
|
+
skipped.push({
|
|
458
|
+
path: candidate,
|
|
459
|
+
reason: 'leased',
|
|
460
|
+
labels: leases.map((lease) => lease.label),
|
|
461
|
+
});
|
|
462
|
+
return;
|
|
463
|
+
}
|
|
464
|
+
const stat = await fsp.stat(candidate).catch(() => null);
|
|
465
|
+
if (stat && now - stat.mtimeMs < graceMs) {
|
|
466
|
+
skipped.push({ path: candidate, reason: 'recent' });
|
|
467
|
+
return;
|
|
468
|
+
}
|
|
469
|
+
try {
|
|
470
|
+
await removeWorktree(candidate);
|
|
471
|
+
removed.push(candidate);
|
|
472
|
+
} catch (error) {
|
|
473
|
+
skipped.push({
|
|
474
|
+
path: candidate,
|
|
475
|
+
reason: 'remove-failed',
|
|
476
|
+
error:
|
|
477
|
+
error instanceof Error
|
|
478
|
+
? error.message.split('\n')[0]
|
|
479
|
+
: String(error),
|
|
480
|
+
});
|
|
481
|
+
}
|
|
482
|
+
});
|
|
412
483
|
}
|
|
413
484
|
return { removed, skipped };
|
|
414
485
|
}
|
|
@@ -418,6 +489,176 @@ async function pygmalionStateDirectory(repoRoot) {
|
|
|
418
489
|
return path.join(path.resolve(repoRoot, rawCommonGitDir), 'pygmalion');
|
|
419
490
|
}
|
|
420
491
|
|
|
492
|
+
async function canonicalDevMirrorPath(target) {
|
|
493
|
+
const resolved = path.resolve(target);
|
|
494
|
+
const direct = await fsp.realpath(resolved).catch(() => null);
|
|
495
|
+
if (direct) return direct;
|
|
496
|
+
const parent = await fsp.realpath(path.dirname(resolved)).catch(() => null);
|
|
497
|
+
return parent ? path.join(parent, path.basename(resolved)) : resolved;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
function processIsAlive(pid) {
|
|
501
|
+
if (!Number.isSafeInteger(pid) || pid <= 0) return null;
|
|
502
|
+
try {
|
|
503
|
+
process.kill(pid, 0);
|
|
504
|
+
return true;
|
|
505
|
+
} catch (error) {
|
|
506
|
+
return error?.code === 'ESRCH' ? false : true;
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
async function acquireDevMirrorReclaimMutex(lockPath, deadline) {
|
|
511
|
+
const reclaimPath = `${lockPath}.reclaim`;
|
|
512
|
+
const token = `${process.pid}:${randomUUID()}`;
|
|
513
|
+
const owner = `${JSON.stringify({
|
|
514
|
+
token,
|
|
515
|
+
pid: process.pid,
|
|
516
|
+
createdAt: new Date().toISOString(),
|
|
517
|
+
})}\n`;
|
|
518
|
+
while (true) {
|
|
519
|
+
const candidatePath = `${reclaimPath}.candidate-${randomUUID()}`;
|
|
520
|
+
try {
|
|
521
|
+
await fsp.writeFile(candidatePath, owner, { flag: 'wx' });
|
|
522
|
+
await fsp.link(candidatePath, reclaimPath);
|
|
523
|
+
return async () => {
|
|
524
|
+
const current = await fsp
|
|
525
|
+
.readFile(reclaimPath, 'utf8')
|
|
526
|
+
.then((raw) => JSON.parse(raw)?.token)
|
|
527
|
+
.catch(() => null);
|
|
528
|
+
if (current === token) {
|
|
529
|
+
await fsp.unlink(reclaimPath).catch(() => undefined);
|
|
530
|
+
}
|
|
531
|
+
};
|
|
532
|
+
} catch (error) {
|
|
533
|
+
if (error?.code !== 'EEXIST') throw error;
|
|
534
|
+
// This guard is deliberately never stale-stolen. If its owner dies, the
|
|
535
|
+
// primary lock remains fail-closed until the guard is removed manually.
|
|
536
|
+
if (Date.now() >= deadline) {
|
|
537
|
+
throw new Error(
|
|
538
|
+
'dev screen refresh on another editor server is not finished',
|
|
539
|
+
);
|
|
540
|
+
}
|
|
541
|
+
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
542
|
+
} finally {
|
|
543
|
+
await fsp.rm(candidatePath, { force: true }).catch(() => undefined);
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
async function reclaimStaleDevMirrorSyncLock({
|
|
549
|
+
lockPath,
|
|
550
|
+
deadline,
|
|
551
|
+
onStaleLockConfirmed,
|
|
552
|
+
}) {
|
|
553
|
+
const releaseReclaim = await acquireDevMirrorReclaimMutex(lockPath, deadline);
|
|
554
|
+
try {
|
|
555
|
+
const staleOwner = async () => {
|
|
556
|
+
const stat = await fsp.stat(lockPath).catch(() => null);
|
|
557
|
+
if (!stat || Date.now() - stat.mtimeMs <= SHARED_LOCK_STALE_MS) {
|
|
558
|
+
return false;
|
|
559
|
+
}
|
|
560
|
+
const ownerState = await fsp
|
|
561
|
+
.readFile(lockPath, 'utf8')
|
|
562
|
+
.then((raw) => processIsAlive(JSON.parse(raw)?.pid))
|
|
563
|
+
.catch(() => null);
|
|
564
|
+
return ownerState !== true;
|
|
565
|
+
};
|
|
566
|
+
if (!(await staleOwner())) return false;
|
|
567
|
+
await onStaleLockConfirmed?.({ lockPath });
|
|
568
|
+
// The callback models a delayed reclaimer. Re-read under the mutex so an
|
|
569
|
+
// observation made before another recovery can never delete its new lock.
|
|
570
|
+
if (!(await staleOwner())) return false;
|
|
571
|
+
await fsp.unlink(lockPath).catch(() => undefined);
|
|
572
|
+
return true;
|
|
573
|
+
} finally {
|
|
574
|
+
await releaseReclaim();
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
/** Serializes mirror mutation and exact read-lease acquisition across processes. */
|
|
579
|
+
export async function withDevMirrorSyncLock(
|
|
580
|
+
repoRoot,
|
|
581
|
+
task,
|
|
582
|
+
{
|
|
583
|
+
onLockCandidateReady,
|
|
584
|
+
onStaleLockConfirmed,
|
|
585
|
+
lockTimeoutMs = SHARED_LOCK_TIMEOUT_MS,
|
|
586
|
+
} = {},
|
|
587
|
+
) {
|
|
588
|
+
const directory = await pygmalionStateDirectory(repoRoot);
|
|
589
|
+
const lockPath = path.join(directory, 'dev-mirror-sync.lock');
|
|
590
|
+
const token = `${process.pid}:${randomUUID()}`;
|
|
591
|
+
const owner = `${JSON.stringify({
|
|
592
|
+
token,
|
|
593
|
+
pid: process.pid,
|
|
594
|
+
createdAt: new Date().toISOString(),
|
|
595
|
+
})}\n`;
|
|
596
|
+
const deadline = Date.now() + lockTimeoutMs;
|
|
597
|
+
await fsp.mkdir(path.dirname(lockPath), { recursive: true });
|
|
598
|
+
|
|
599
|
+
while (true) {
|
|
600
|
+
const candidatePath = `${lockPath}.candidate-${randomUUID()}`;
|
|
601
|
+
try {
|
|
602
|
+
// Write ownership completely before the fixed lock path becomes visible.
|
|
603
|
+
// If this process sleeps between file creation and the write, contenders
|
|
604
|
+
// see only a private candidate rather than an apparently abandoned lock.
|
|
605
|
+
await fsp.writeFile(candidatePath, owner, { flag: 'wx' });
|
|
606
|
+
await onLockCandidateReady?.({ candidatePath, lockPath });
|
|
607
|
+
await fsp.link(candidatePath, lockPath);
|
|
608
|
+
break;
|
|
609
|
+
} catch (error) {
|
|
610
|
+
if (
|
|
611
|
+
!(
|
|
612
|
+
error &&
|
|
613
|
+
typeof error === 'object' &&
|
|
614
|
+
'code' in error &&
|
|
615
|
+
error.code === 'EEXIST'
|
|
616
|
+
)
|
|
617
|
+
) {
|
|
618
|
+
throw error;
|
|
619
|
+
}
|
|
620
|
+
const stale = await fsp
|
|
621
|
+
.stat(lockPath)
|
|
622
|
+
.then((stat) => Date.now() - stat.mtimeMs > SHARED_LOCK_STALE_MS)
|
|
623
|
+
.catch(() => false);
|
|
624
|
+
if (stale) {
|
|
625
|
+
const reclaimed = await reclaimStaleDevMirrorSyncLock({
|
|
626
|
+
lockPath,
|
|
627
|
+
deadline,
|
|
628
|
+
onStaleLockConfirmed,
|
|
629
|
+
});
|
|
630
|
+
if (reclaimed) {
|
|
631
|
+
continue;
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
if (Date.now() >= deadline) {
|
|
635
|
+
throw new Error(
|
|
636
|
+
'dev screen refresh on another editor server is not finished',
|
|
637
|
+
);
|
|
638
|
+
}
|
|
639
|
+
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
640
|
+
} finally {
|
|
641
|
+
await fsp.rm(candidatePath, { force: true }).catch(() => undefined);
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
const heartbeat = setInterval(() => {
|
|
646
|
+
const now = new Date();
|
|
647
|
+
void fsp.utimes(lockPath, now, now).catch(() => undefined);
|
|
648
|
+
}, 2_000);
|
|
649
|
+
heartbeat.unref();
|
|
650
|
+
try {
|
|
651
|
+
return await task();
|
|
652
|
+
} finally {
|
|
653
|
+
clearInterval(heartbeat);
|
|
654
|
+
const owner = await fsp
|
|
655
|
+
.readFile(lockPath, 'utf8')
|
|
656
|
+
.then((raw) => JSON.parse(raw).token)
|
|
657
|
+
.catch(() => null);
|
|
658
|
+
if (owner === token) await fsp.unlink(lockPath).catch(() => undefined);
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
|
|
421
662
|
/**
|
|
422
663
|
* Downgrades a ready status whose checkout no longer holds the commit it claims.
|
|
423
664
|
*
|
|
@@ -455,35 +696,39 @@ async function freshLeases(repoRoot, mirrorRoot) {
|
|
|
455
696
|
return [];
|
|
456
697
|
}
|
|
457
698
|
const held = [];
|
|
699
|
+
const canonicalMirrorRoot = mirrorRoot
|
|
700
|
+
? await canonicalDevMirrorPath(mirrorRoot)
|
|
701
|
+
: null;
|
|
458
702
|
for (const entry of entries) {
|
|
459
703
|
if (!entry.endsWith('.json')) continue;
|
|
460
704
|
const file = path.join(directory, entry);
|
|
461
705
|
const stat = await fsp.stat(file).catch(() => null);
|
|
462
706
|
if (!stat) continue;
|
|
463
|
-
if (Date.now() - stat.mtimeMs > LEASE_STALE_MS) {
|
|
464
|
-
await fsp.rm(file, { force: true }).catch(() => undefined);
|
|
465
|
-
continue;
|
|
466
|
-
}
|
|
467
707
|
const lease = await fsp
|
|
468
708
|
.readFile(file, 'utf8')
|
|
469
709
|
.then((raw) => JSON.parse(raw))
|
|
470
710
|
.catch(() => null);
|
|
711
|
+
if (Date.now() - stat.mtimeMs > LEASE_STALE_MS) {
|
|
712
|
+
const ownerState = processIsAlive(lease?.pid);
|
|
713
|
+
if (ownerState !== true) {
|
|
714
|
+
await fsp.rm(file, { force: true }).catch(() => undefined);
|
|
715
|
+
continue;
|
|
716
|
+
}
|
|
717
|
+
}
|
|
471
718
|
if (!lease) continue;
|
|
472
|
-
if (
|
|
719
|
+
if (canonicalMirrorRoot) {
|
|
720
|
+
if (typeof lease.mirrorRoot !== 'string' || !lease.mirrorRoot.trim()) {
|
|
721
|
+
continue;
|
|
722
|
+
}
|
|
723
|
+
const canonicalLeaseRoot = await canonicalDevMirrorPath(lease.mirrorRoot);
|
|
724
|
+
if (canonicalLeaseRoot !== canonicalMirrorRoot) continue;
|
|
725
|
+
}
|
|
473
726
|
held.push({ ...lease, file });
|
|
474
727
|
}
|
|
475
728
|
return held;
|
|
476
729
|
}
|
|
477
730
|
|
|
478
|
-
|
|
479
|
-
* Marks a mirror checkout as being read, so a sync cannot switch it away.
|
|
480
|
-
*
|
|
481
|
-
* A screen capture reads the checkout for minutes at a time while the sync lock
|
|
482
|
-
* is free, which is exactly when another server could move it. The lease is a
|
|
483
|
-
* heartbeat file: it expires on its own if the reader dies, so a crash cannot
|
|
484
|
-
* wedge the mirror.
|
|
485
|
-
*/
|
|
486
|
-
export async function acquireDevMirrorLease({ repoRoot, mirrorRoot, label = 'capture' }) {
|
|
731
|
+
async function writeDevMirrorLease({ repoRoot, mirrorRoot, label = 'capture' }) {
|
|
487
732
|
const directory = path.join(await pygmalionStateDirectory(repoRoot), 'dev-mirror-leases');
|
|
488
733
|
await fsp.mkdir(directory, { recursive: true });
|
|
489
734
|
const file = path.join(directory, `${randomUUID()}.json`);
|
|
@@ -513,6 +758,100 @@ export async function acquireDevMirrorLease({ repoRoot, mirrorRoot, label = 'cap
|
|
|
513
758
|
};
|
|
514
759
|
}
|
|
515
760
|
|
|
761
|
+
/**
|
|
762
|
+
* Marks a mirror checkout as being read, so a sync or reaper cannot move it.
|
|
763
|
+
*
|
|
764
|
+
* Creation is serialized with mirror mutation. The lease remains a heartbeat
|
|
765
|
+
* file after acquisition, so the shared lock is free while the reader works and
|
|
766
|
+
* a crashed reader cannot wedge the checkout permanently.
|
|
767
|
+
*/
|
|
768
|
+
export async function acquireDevMirrorLease(options) {
|
|
769
|
+
return withDevMirrorSyncLock(options.repoRoot, () =>
|
|
770
|
+
writeDevMirrorLease(options),
|
|
771
|
+
);
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
/**
|
|
775
|
+
* Acquires a read lease and proves that its root still carries the requested
|
|
776
|
+
* commit after the lease exists. `readCurrent` is sampled on both sides so a
|
|
777
|
+
* sync that started first is rejected, while a sync that starts afterwards is
|
|
778
|
+
* held by the lease.
|
|
779
|
+
*/
|
|
780
|
+
export async function acquireExactDevMirrorSourceLease({
|
|
781
|
+
repoRoot,
|
|
782
|
+
expectedRevision,
|
|
783
|
+
readCurrent,
|
|
784
|
+
withSyncLock,
|
|
785
|
+
label = 'source reader',
|
|
786
|
+
}) {
|
|
787
|
+
if (
|
|
788
|
+
typeof expectedRevision !== 'string' ||
|
|
789
|
+
!expectedRevision.trim() ||
|
|
790
|
+
expectedRevision !== expectedRevision.trim() ||
|
|
791
|
+
typeof readCurrent !== 'function' ||
|
|
792
|
+
typeof withSyncLock !== 'function'
|
|
793
|
+
) {
|
|
794
|
+
return null;
|
|
795
|
+
}
|
|
796
|
+
return withSyncLock(async () => {
|
|
797
|
+
const before = readCurrent();
|
|
798
|
+
if (
|
|
799
|
+
before?.state !== 'ready' ||
|
|
800
|
+
before.sourceRevision !== expectedRevision ||
|
|
801
|
+
typeof before.mirrorRoot !== 'string' ||
|
|
802
|
+
typeof before.appRoot !== 'string'
|
|
803
|
+
) {
|
|
804
|
+
return null;
|
|
805
|
+
}
|
|
806
|
+
const capturedMirrorRoot = path.resolve(before.mirrorRoot);
|
|
807
|
+
const capturedAppRoot = path.resolve(before.appRoot);
|
|
808
|
+
// The caller already owns the same cross-process lock used by the public
|
|
809
|
+
// lease API. Writing directly avoids a nested acquisition deadlock.
|
|
810
|
+
const lease = await writeDevMirrorLease({
|
|
811
|
+
repoRoot,
|
|
812
|
+
mirrorRoot: capturedMirrorRoot,
|
|
813
|
+
label,
|
|
814
|
+
});
|
|
815
|
+
const release = async () => lease.release();
|
|
816
|
+
try {
|
|
817
|
+
const afterLease = readCurrent();
|
|
818
|
+
if (
|
|
819
|
+
afterLease?.state !== 'ready' ||
|
|
820
|
+
afterLease.sourceRevision !== expectedRevision ||
|
|
821
|
+
path.resolve(afterLease.mirrorRoot ?? '') !== capturedMirrorRoot ||
|
|
822
|
+
path.resolve(afterLease.appRoot ?? '') !== capturedAppRoot
|
|
823
|
+
) {
|
|
824
|
+
await release();
|
|
825
|
+
return null;
|
|
826
|
+
}
|
|
827
|
+
const exactSourceRoot = await resolveExactCheckoutSourceRoot({
|
|
828
|
+
checkoutRoot: capturedMirrorRoot,
|
|
829
|
+
sourceRoot: capturedAppRoot,
|
|
830
|
+
expectedRevision,
|
|
831
|
+
});
|
|
832
|
+
const afterRead = readCurrent();
|
|
833
|
+
if (
|
|
834
|
+
!exactSourceRoot ||
|
|
835
|
+
afterRead?.state !== 'ready' ||
|
|
836
|
+
afterRead.sourceRevision !== expectedRevision ||
|
|
837
|
+
path.resolve(afterRead.mirrorRoot ?? '') !== capturedMirrorRoot ||
|
|
838
|
+
path.resolve(afterRead.appRoot ?? '') !== capturedAppRoot
|
|
839
|
+
) {
|
|
840
|
+
await release();
|
|
841
|
+
return null;
|
|
842
|
+
}
|
|
843
|
+
return {
|
|
844
|
+
sourceRoot: exactSourceRoot,
|
|
845
|
+
sourceRevision: expectedRevision,
|
|
846
|
+
release,
|
|
847
|
+
};
|
|
848
|
+
} catch (error) {
|
|
849
|
+
await release();
|
|
850
|
+
throw error;
|
|
851
|
+
}
|
|
852
|
+
});
|
|
853
|
+
}
|
|
854
|
+
|
|
516
855
|
/**
|
|
517
856
|
* Leases that a sync must wait out.
|
|
518
857
|
*
|
|
@@ -622,6 +961,127 @@ function normalizeManagedOutputPaths(value) {
|
|
|
622
961
|
];
|
|
623
962
|
}
|
|
624
963
|
|
|
964
|
+
function normalizeInventoryRuntimeInputPaths(value) {
|
|
965
|
+
if (value == null) return [];
|
|
966
|
+
if (!Array.isArray(value)) {
|
|
967
|
+
throw new Error('Pygmalion inventory runtimeInputs must be an array');
|
|
968
|
+
}
|
|
969
|
+
if (value.length > MAX_INVENTORY_RUNTIME_INPUTS) {
|
|
970
|
+
throw new Error(
|
|
971
|
+
`Pygmalion inventory runtimeInputs cannot contain more than ${MAX_INVENTORY_RUNTIME_INPUTS} entries`,
|
|
972
|
+
);
|
|
973
|
+
}
|
|
974
|
+
return [
|
|
975
|
+
...new Set(
|
|
976
|
+
value.map((entry, index) => {
|
|
977
|
+
if (typeof entry !== 'string' || entry.trim() === '') {
|
|
978
|
+
throw new Error(
|
|
979
|
+
`Pygmalion inventory runtime input ${index} must be a non-empty string`,
|
|
980
|
+
);
|
|
981
|
+
}
|
|
982
|
+
const candidate = entry.trim().replaceAll('\\', '/');
|
|
983
|
+
const segments = candidate.split('/');
|
|
984
|
+
if (
|
|
985
|
+
path.posix.isAbsolute(candidate) ||
|
|
986
|
+
/^[a-z]:\//iu.test(candidate) ||
|
|
987
|
+
segments.includes('..')
|
|
988
|
+
) {
|
|
989
|
+
throw new Error(
|
|
990
|
+
`Pygmalion inventory runtime input must stay inside the application root: ${entry}`,
|
|
991
|
+
);
|
|
992
|
+
}
|
|
993
|
+
const normalized = path.posix.normalize(candidate).replace(/^\.\//u, '');
|
|
994
|
+
if (normalized === '.' || normalized === '') {
|
|
995
|
+
throw new Error(
|
|
996
|
+
'Pygmalion inventory runtime input cannot target the application root',
|
|
997
|
+
);
|
|
998
|
+
}
|
|
999
|
+
return normalized;
|
|
1000
|
+
}),
|
|
1001
|
+
),
|
|
1002
|
+
].sort((left, right) => left.localeCompare(right));
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
/**
|
|
1006
|
+
* Fingerprints only explicitly declared runtime input files. This protects
|
|
1007
|
+
* ignored configuration without walking large unrelated trees such as .git or
|
|
1008
|
+
* node_modules.
|
|
1009
|
+
*/
|
|
1010
|
+
export async function fingerprintDevMirrorRuntimeInputs(
|
|
1011
|
+
appRoot,
|
|
1012
|
+
runtimeInputs = [],
|
|
1013
|
+
) {
|
|
1014
|
+
const resolvedRoot = path.resolve(appRoot);
|
|
1015
|
+
const realRoot = await fsp.realpath(resolvedRoot);
|
|
1016
|
+
const inputs = normalizeInventoryRuntimeInputPaths(runtimeInputs);
|
|
1017
|
+
const digest = createHash('sha256');
|
|
1018
|
+
digest.update('pygmalion-inventory-runtime-inputs-v1\0');
|
|
1019
|
+
let totalBytes = 0;
|
|
1020
|
+
for (const input of inputs) {
|
|
1021
|
+
const file = path.resolve(resolvedRoot, input);
|
|
1022
|
+
let entry;
|
|
1023
|
+
try {
|
|
1024
|
+
entry = await fsp.lstat(file);
|
|
1025
|
+
} catch (error) {
|
|
1026
|
+
if (error?.code !== 'ENOENT') throw error;
|
|
1027
|
+
digest.update(`${input}\0missing\0`);
|
|
1028
|
+
continue;
|
|
1029
|
+
}
|
|
1030
|
+
const realFile = await fsp.realpath(file);
|
|
1031
|
+
if (
|
|
1032
|
+
realFile !== realRoot &&
|
|
1033
|
+
!realFile.startsWith(`${realRoot}${path.sep}`)
|
|
1034
|
+
) {
|
|
1035
|
+
throw new Error(
|
|
1036
|
+
`Pygmalion inventory runtime input must stay inside the application root: ${input}`,
|
|
1037
|
+
);
|
|
1038
|
+
}
|
|
1039
|
+
const target = entry.isSymbolicLink() ? await fsp.stat(realFile) : entry;
|
|
1040
|
+
if (!target.isFile()) {
|
|
1041
|
+
throw new Error(
|
|
1042
|
+
`Pygmalion inventory runtime input must be a file, not a directory: ${input}`,
|
|
1043
|
+
);
|
|
1044
|
+
}
|
|
1045
|
+
totalBytes += target.size;
|
|
1046
|
+
if (totalBytes > MAX_INVENTORY_RUNTIME_INPUT_BYTES) {
|
|
1047
|
+
throw new Error(
|
|
1048
|
+
`Pygmalion inventory runtime inputs exceed ${MAX_INVENTORY_RUNTIME_INPUT_BYTES} bytes`,
|
|
1049
|
+
);
|
|
1050
|
+
}
|
|
1051
|
+
const contents = await fsp.readFile(realFile);
|
|
1052
|
+
if (contents.byteLength !== target.size) {
|
|
1053
|
+
throw new Error(
|
|
1054
|
+
`Pygmalion inventory runtime input changed while it was read: ${input}`,
|
|
1055
|
+
);
|
|
1056
|
+
}
|
|
1057
|
+
digest.update(input);
|
|
1058
|
+
digest.update('\0file\0');
|
|
1059
|
+
if (entry.isSymbolicLink()) {
|
|
1060
|
+
digest.update(await fsp.readlink(file));
|
|
1061
|
+
digest.update('\0');
|
|
1062
|
+
}
|
|
1063
|
+
digest.update(contents);
|
|
1064
|
+
digest.update('\0');
|
|
1065
|
+
}
|
|
1066
|
+
return digest.digest('hex');
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
/** Changes outside generator-owned paths make a checkout inexact. */
|
|
1070
|
+
export async function devMirrorUnexpectedChanges(mirrorRoot, managedPaths = []) {
|
|
1071
|
+
const exclusions = normalizeManagedOutputPaths(managedPaths).map(
|
|
1072
|
+
(entry) => `:(top,exclude,literal)${entry}`,
|
|
1073
|
+
);
|
|
1074
|
+
return git(
|
|
1075
|
+
path.resolve(mirrorRoot),
|
|
1076
|
+
'status',
|
|
1077
|
+
'--porcelain',
|
|
1078
|
+
'--untracked-files=all',
|
|
1079
|
+
'--',
|
|
1080
|
+
'.',
|
|
1081
|
+
...exclusions,
|
|
1082
|
+
);
|
|
1083
|
+
}
|
|
1084
|
+
|
|
625
1085
|
const MAX_REFRESH_BODY_BYTES = 4096;
|
|
626
1086
|
|
|
627
1087
|
/**
|
|
@@ -866,6 +1326,20 @@ export function pygmalionDevMirrorPlugin(options) {
|
|
|
866
1326
|
const previewMode = normalizeViteMode(options.previewMode);
|
|
867
1327
|
const inventory = options.inventory;
|
|
868
1328
|
const inventoryOutputs = normalizeManagedOutputPaths(inventory?.outputs);
|
|
1329
|
+
const inventoryRuntimeInputs = normalizeInventoryRuntimeInputPaths(
|
|
1330
|
+
inventory?.runtimeInputs,
|
|
1331
|
+
);
|
|
1332
|
+
for (const input of inventoryRuntimeInputs) {
|
|
1333
|
+
if (
|
|
1334
|
+
inventoryOutputs.some(
|
|
1335
|
+
(output) => input === output || input.startsWith(`${output}/`),
|
|
1336
|
+
)
|
|
1337
|
+
) {
|
|
1338
|
+
throw new Error(
|
|
1339
|
+
`Pygmalion inventory runtime input must stay outside inventory.outputs: ${input}`,
|
|
1340
|
+
);
|
|
1341
|
+
}
|
|
1342
|
+
}
|
|
869
1343
|
let managedPaths = [];
|
|
870
1344
|
const applyMirrorPathsForRef = (activeRef) => {
|
|
871
1345
|
const resolved = resolveDevMirrorWorktreePaths({
|
|
@@ -920,7 +1394,6 @@ export function pygmalionDevMirrorPlugin(options) {
|
|
|
920
1394
|
let previewPort = null;
|
|
921
1395
|
let previewAppRoot = null;
|
|
922
1396
|
let syncPromise = null;
|
|
923
|
-
let sharedLockPathPromise = null;
|
|
924
1397
|
// Assigned once the server is configured. A capture asks for the checkout
|
|
925
1398
|
// through the composition handle below, and that demand has to reach the same
|
|
926
1399
|
// starter the preview-origin requests use.
|
|
@@ -939,62 +1412,7 @@ export function pygmalionDevMirrorPlugin(options) {
|
|
|
939
1412
|
appOrigin: null,
|
|
940
1413
|
};
|
|
941
1414
|
|
|
942
|
-
const
|
|
943
|
-
if (!sharedLockPathPromise) {
|
|
944
|
-
sharedLockPathPromise = (async () => {
|
|
945
|
-
const rawCommonGitDir = await git(repoRoot, 'rev-parse', '--git-common-dir');
|
|
946
|
-
const commonGitDir = path.resolve(repoRoot, rawCommonGitDir);
|
|
947
|
-
return path.join(commonGitDir, 'pygmalion', 'dev-mirror-sync.lock');
|
|
948
|
-
})();
|
|
949
|
-
}
|
|
950
|
-
return sharedLockPathPromise;
|
|
951
|
-
};
|
|
952
|
-
|
|
953
|
-
const withSharedSyncLock = async (task) => {
|
|
954
|
-
const lockPath = await sharedLockPath();
|
|
955
|
-
const token = `${process.pid}:${randomUUID()}`;
|
|
956
|
-
const deadline = Date.now() + SHARED_LOCK_TIMEOUT_MS;
|
|
957
|
-
await fsp.mkdir(path.dirname(lockPath), { recursive: true });
|
|
958
|
-
|
|
959
|
-
while (true) {
|
|
960
|
-
try {
|
|
961
|
-
const handle = await fsp.open(lockPath, 'wx');
|
|
962
|
-
await handle.writeFile(`${JSON.stringify({ token, pid: process.pid, createdAt: new Date().toISOString() })}\n`);
|
|
963
|
-
await handle.close();
|
|
964
|
-
break;
|
|
965
|
-
} catch (error) {
|
|
966
|
-
if (!(error && typeof error === 'object' && 'code' in error && error.code === 'EEXIST')) throw error;
|
|
967
|
-
const stale = await fsp
|
|
968
|
-
.stat(lockPath)
|
|
969
|
-
.then((stat) => Date.now() - stat.mtimeMs > SHARED_LOCK_STALE_MS)
|
|
970
|
-
.catch(() => false);
|
|
971
|
-
if (stale) {
|
|
972
|
-
await fsp.unlink(lockPath).catch(() => undefined);
|
|
973
|
-
continue;
|
|
974
|
-
}
|
|
975
|
-
if (Date.now() >= deadline) {
|
|
976
|
-
throw new Error('dev screen refresh on another editor server is not finished');
|
|
977
|
-
}
|
|
978
|
-
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
979
|
-
}
|
|
980
|
-
}
|
|
981
|
-
|
|
982
|
-
const heartbeat = setInterval(() => {
|
|
983
|
-
const now = new Date();
|
|
984
|
-
void fsp.utimes(lockPath, now, now).catch(() => undefined);
|
|
985
|
-
}, 2_000);
|
|
986
|
-
heartbeat.unref();
|
|
987
|
-
try {
|
|
988
|
-
return await task();
|
|
989
|
-
} finally {
|
|
990
|
-
clearInterval(heartbeat);
|
|
991
|
-
const owner = await fsp
|
|
992
|
-
.readFile(lockPath, 'utf8')
|
|
993
|
-
.then((raw) => JSON.parse(raw).token)
|
|
994
|
-
.catch(() => null);
|
|
995
|
-
if (owner === token) await fsp.unlink(lockPath).catch(() => undefined);
|
|
996
|
-
}
|
|
997
|
-
};
|
|
1415
|
+
const withSharedSyncLock = (task) => withDevMirrorSyncLock(repoRoot, task);
|
|
998
1416
|
|
|
999
1417
|
/**
|
|
1000
1418
|
* Confirms the served checkout still is the commit the status claims.
|
|
@@ -1023,6 +1441,27 @@ export function pygmalionDevMirrorPlugin(options) {
|
|
|
1023
1441
|
return status;
|
|
1024
1442
|
};
|
|
1025
1443
|
|
|
1444
|
+
/**
|
|
1445
|
+
* Acquires one coherent checkout identity for readers such as the revision
|
|
1446
|
+
* catalog endpoint. The second status check closes the reader/sync race: a
|
|
1447
|
+
* sync announces `syncing` before it waits for leases, while a sync starting
|
|
1448
|
+
* after this check must observe the lease before it can move the checkout.
|
|
1449
|
+
*/
|
|
1450
|
+
const acquireSourceLease = async (expectedRevision, label = 'source reader') => {
|
|
1451
|
+
return acquireExactDevMirrorSourceLease({
|
|
1452
|
+
repoRoot,
|
|
1453
|
+
expectedRevision,
|
|
1454
|
+
label,
|
|
1455
|
+
withSyncLock: withSharedSyncLock,
|
|
1456
|
+
readCurrent: () => ({
|
|
1457
|
+
state: status.state,
|
|
1458
|
+
sourceRevision: status.commit,
|
|
1459
|
+
mirrorRoot,
|
|
1460
|
+
appRoot: mirrorAppRoot,
|
|
1461
|
+
}),
|
|
1462
|
+
});
|
|
1463
|
+
};
|
|
1464
|
+
|
|
1026
1465
|
const stopPreview = async () => {
|
|
1027
1466
|
const child = previewChild;
|
|
1028
1467
|
previewChild = null;
|
|
@@ -1093,17 +1532,7 @@ export function pygmalionDevMirrorPlugin(options) {
|
|
|
1093
1532
|
}
|
|
1094
1533
|
if (!inventory.script && !inventory.command) return;
|
|
1095
1534
|
const command = inventory.command ?? process.execPath;
|
|
1096
|
-
const args =
|
|
1097
|
-
typeof inventory.args === 'function'
|
|
1098
|
-
? inventory.args(context)
|
|
1099
|
-
: inventory.args ??
|
|
1100
|
-
[
|
|
1101
|
-
inventory.script,
|
|
1102
|
-
'--source-root',
|
|
1103
|
-
context.sourceRoot,
|
|
1104
|
-
'--out-root',
|
|
1105
|
-
context.outputRoot,
|
|
1106
|
-
];
|
|
1535
|
+
const args = resolveDevMirrorInventoryArgs(inventory, context);
|
|
1107
1536
|
await run(command, args, { cwd: path.resolve(inventory.cwd ?? editorRoot) });
|
|
1108
1537
|
};
|
|
1109
1538
|
|
|
@@ -1231,7 +1660,29 @@ export function pygmalionDevMirrorPlugin(options) {
|
|
|
1231
1660
|
const { previous, commit, shortCommit } = mirrorState;
|
|
1232
1661
|
await claimDevMirror(repoRoot, mirrorRoot, ownerToken);
|
|
1233
1662
|
const dependenciesChanged = await syncDependencies();
|
|
1663
|
+
const runtimeInputsBefore = await fingerprintDevMirrorRuntimeInputs(
|
|
1664
|
+
mirrorAppRoot,
|
|
1665
|
+
inventoryRuntimeInputs,
|
|
1666
|
+
);
|
|
1234
1667
|
await generateInventory(commit);
|
|
1668
|
+
const runtimeInputsAfter = await fingerprintDevMirrorRuntimeInputs(
|
|
1669
|
+
mirrorAppRoot,
|
|
1670
|
+
inventoryRuntimeInputs,
|
|
1671
|
+
);
|
|
1672
|
+
if (runtimeInputsAfter !== runtimeInputsBefore) {
|
|
1673
|
+
throw new Error(
|
|
1674
|
+
'Inventory generation changed a declared inventory.runtimeInputs file.',
|
|
1675
|
+
);
|
|
1676
|
+
}
|
|
1677
|
+
const unexpectedChanges = await devMirrorUnexpectedChanges(
|
|
1678
|
+
mirrorRoot,
|
|
1679
|
+
managedPaths,
|
|
1680
|
+
);
|
|
1681
|
+
if (unexpectedChanges) {
|
|
1682
|
+
throw new Error(
|
|
1683
|
+
`Inventory generation changed a path outside inventory.outputs: ${unexpectedChanges.split('\n')[0]}`,
|
|
1684
|
+
);
|
|
1685
|
+
}
|
|
1235
1686
|
await startPreview(dependenciesChanged, commit);
|
|
1236
1687
|
|
|
1237
1688
|
revision += 1;
|
|
@@ -1271,6 +1722,7 @@ export function pygmalionDevMirrorPlugin(options) {
|
|
|
1271
1722
|
// Pygmalion's own plugins compose through it.
|
|
1272
1723
|
pygmalion: {
|
|
1273
1724
|
acquireLease: (label) => acquireDevMirrorLease({ repoRoot, mirrorRoot, label }),
|
|
1725
|
+
acquireSourceLease,
|
|
1274
1726
|
activeMirrorRoot: () => mirrorRoot,
|
|
1275
1727
|
activeAppRoot: () => mirrorAppRoot,
|
|
1276
1728
|
// A capture needs the checkout as much as a live frame does, but it asks
|