@pygmalionjs/pygmalion 0.6.32 → 0.6.34

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.
@@ -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 real = async (target) => {
367
- const resolved = path.resolve(target);
368
- const direct = await fsp.realpath(resolved).catch(() => null);
369
- if (direct) return direct;
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) => real(line.slice('worktree '.length).trim())),
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
- const leases = await freshLeases(repoRoot, candidate);
393
- if (leases.length > 0) {
394
- skipped.push({ path: candidate, reason: 'leased', labels: leases.map((l) => l.label) });
395
- continue;
396
- }
397
- const stat = await fsp.stat(candidate).catch(() => null);
398
- if (stat && now - stat.mtimeMs < graceMs) {
399
- skipped.push({ path: candidate, reason: 'recent' });
400
- continue;
401
- }
402
- try {
403
- await git(repoRoot, 'worktree', 'remove', '--force', candidate);
404
- removed.push(candidate);
405
- } catch (error) {
406
- skipped.push({
407
- path: candidate,
408
- reason: 'remove-failed',
409
- error: error instanceof Error ? error.message.split('\n')[0] : String(error),
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 (mirrorRoot && path.resolve(lease.mirrorRoot ?? '') !== path.resolve(mirrorRoot)) continue;
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,103 @@ 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
+ // Synchronization and reaping honor the heartbeat lease until release,
847
+ // so retention may safely inspect this root while the caller holds it.
848
+ sourceRootStable: true,
849
+ release,
850
+ };
851
+ } catch (error) {
852
+ await release();
853
+ throw error;
854
+ }
855
+ });
856
+ }
857
+
516
858
  /**
517
859
  * Leases that a sync must wait out.
518
860
  *
@@ -622,6 +964,127 @@ function normalizeManagedOutputPaths(value) {
622
964
  ];
623
965
  }
624
966
 
967
+ function normalizeInventoryRuntimeInputPaths(value) {
968
+ if (value == null) return [];
969
+ if (!Array.isArray(value)) {
970
+ throw new Error('Pygmalion inventory runtimeInputs must be an array');
971
+ }
972
+ if (value.length > MAX_INVENTORY_RUNTIME_INPUTS) {
973
+ throw new Error(
974
+ `Pygmalion inventory runtimeInputs cannot contain more than ${MAX_INVENTORY_RUNTIME_INPUTS} entries`,
975
+ );
976
+ }
977
+ return [
978
+ ...new Set(
979
+ value.map((entry, index) => {
980
+ if (typeof entry !== 'string' || entry.trim() === '') {
981
+ throw new Error(
982
+ `Pygmalion inventory runtime input ${index} must be a non-empty string`,
983
+ );
984
+ }
985
+ const candidate = entry.trim().replaceAll('\\', '/');
986
+ const segments = candidate.split('/');
987
+ if (
988
+ path.posix.isAbsolute(candidate) ||
989
+ /^[a-z]:\//iu.test(candidate) ||
990
+ segments.includes('..')
991
+ ) {
992
+ throw new Error(
993
+ `Pygmalion inventory runtime input must stay inside the application root: ${entry}`,
994
+ );
995
+ }
996
+ const normalized = path.posix.normalize(candidate).replace(/^\.\//u, '');
997
+ if (normalized === '.' || normalized === '') {
998
+ throw new Error(
999
+ 'Pygmalion inventory runtime input cannot target the application root',
1000
+ );
1001
+ }
1002
+ return normalized;
1003
+ }),
1004
+ ),
1005
+ ].sort((left, right) => left.localeCompare(right));
1006
+ }
1007
+
1008
+ /**
1009
+ * Fingerprints only explicitly declared runtime input files. This protects
1010
+ * ignored configuration without walking large unrelated trees such as .git or
1011
+ * node_modules.
1012
+ */
1013
+ export async function fingerprintDevMirrorRuntimeInputs(
1014
+ appRoot,
1015
+ runtimeInputs = [],
1016
+ ) {
1017
+ const resolvedRoot = path.resolve(appRoot);
1018
+ const realRoot = await fsp.realpath(resolvedRoot);
1019
+ const inputs = normalizeInventoryRuntimeInputPaths(runtimeInputs);
1020
+ const digest = createHash('sha256');
1021
+ digest.update('pygmalion-inventory-runtime-inputs-v1\0');
1022
+ let totalBytes = 0;
1023
+ for (const input of inputs) {
1024
+ const file = path.resolve(resolvedRoot, input);
1025
+ let entry;
1026
+ try {
1027
+ entry = await fsp.lstat(file);
1028
+ } catch (error) {
1029
+ if (error?.code !== 'ENOENT') throw error;
1030
+ digest.update(`${input}\0missing\0`);
1031
+ continue;
1032
+ }
1033
+ const realFile = await fsp.realpath(file);
1034
+ if (
1035
+ realFile !== realRoot &&
1036
+ !realFile.startsWith(`${realRoot}${path.sep}`)
1037
+ ) {
1038
+ throw new Error(
1039
+ `Pygmalion inventory runtime input must stay inside the application root: ${input}`,
1040
+ );
1041
+ }
1042
+ const target = entry.isSymbolicLink() ? await fsp.stat(realFile) : entry;
1043
+ if (!target.isFile()) {
1044
+ throw new Error(
1045
+ `Pygmalion inventory runtime input must be a file, not a directory: ${input}`,
1046
+ );
1047
+ }
1048
+ totalBytes += target.size;
1049
+ if (totalBytes > MAX_INVENTORY_RUNTIME_INPUT_BYTES) {
1050
+ throw new Error(
1051
+ `Pygmalion inventory runtime inputs exceed ${MAX_INVENTORY_RUNTIME_INPUT_BYTES} bytes`,
1052
+ );
1053
+ }
1054
+ const contents = await fsp.readFile(realFile);
1055
+ if (contents.byteLength !== target.size) {
1056
+ throw new Error(
1057
+ `Pygmalion inventory runtime input changed while it was read: ${input}`,
1058
+ );
1059
+ }
1060
+ digest.update(input);
1061
+ digest.update('\0file\0');
1062
+ if (entry.isSymbolicLink()) {
1063
+ digest.update(await fsp.readlink(file));
1064
+ digest.update('\0');
1065
+ }
1066
+ digest.update(contents);
1067
+ digest.update('\0');
1068
+ }
1069
+ return digest.digest('hex');
1070
+ }
1071
+
1072
+ /** Changes outside generator-owned paths make a checkout inexact. */
1073
+ export async function devMirrorUnexpectedChanges(mirrorRoot, managedPaths = []) {
1074
+ const exclusions = normalizeManagedOutputPaths(managedPaths).map(
1075
+ (entry) => `:(top,exclude,literal)${entry}`,
1076
+ );
1077
+ return git(
1078
+ path.resolve(mirrorRoot),
1079
+ 'status',
1080
+ '--porcelain',
1081
+ '--untracked-files=all',
1082
+ '--',
1083
+ '.',
1084
+ ...exclusions,
1085
+ );
1086
+ }
1087
+
625
1088
  const MAX_REFRESH_BODY_BYTES = 4096;
626
1089
 
627
1090
  /**
@@ -866,6 +1329,20 @@ export function pygmalionDevMirrorPlugin(options) {
866
1329
  const previewMode = normalizeViteMode(options.previewMode);
867
1330
  const inventory = options.inventory;
868
1331
  const inventoryOutputs = normalizeManagedOutputPaths(inventory?.outputs);
1332
+ const inventoryRuntimeInputs = normalizeInventoryRuntimeInputPaths(
1333
+ inventory?.runtimeInputs,
1334
+ );
1335
+ for (const input of inventoryRuntimeInputs) {
1336
+ if (
1337
+ inventoryOutputs.some(
1338
+ (output) => input === output || input.startsWith(`${output}/`),
1339
+ )
1340
+ ) {
1341
+ throw new Error(
1342
+ `Pygmalion inventory runtime input must stay outside inventory.outputs: ${input}`,
1343
+ );
1344
+ }
1345
+ }
869
1346
  let managedPaths = [];
870
1347
  const applyMirrorPathsForRef = (activeRef) => {
871
1348
  const resolved = resolveDevMirrorWorktreePaths({
@@ -920,7 +1397,6 @@ export function pygmalionDevMirrorPlugin(options) {
920
1397
  let previewPort = null;
921
1398
  let previewAppRoot = null;
922
1399
  let syncPromise = null;
923
- let sharedLockPathPromise = null;
924
1400
  // Assigned once the server is configured. A capture asks for the checkout
925
1401
  // through the composition handle below, and that demand has to reach the same
926
1402
  // starter the preview-origin requests use.
@@ -939,62 +1415,7 @@ export function pygmalionDevMirrorPlugin(options) {
939
1415
  appOrigin: null,
940
1416
  };
941
1417
 
942
- const sharedLockPath = async () => {
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
- };
1418
+ const withSharedSyncLock = (task) => withDevMirrorSyncLock(repoRoot, task);
998
1419
 
999
1420
  /**
1000
1421
  * Confirms the served checkout still is the commit the status claims.
@@ -1023,6 +1444,27 @@ export function pygmalionDevMirrorPlugin(options) {
1023
1444
  return status;
1024
1445
  };
1025
1446
 
1447
+ /**
1448
+ * Acquires one coherent checkout identity for readers such as the revision
1449
+ * catalog endpoint. The second status check closes the reader/sync race: a
1450
+ * sync announces `syncing` before it waits for leases, while a sync starting
1451
+ * after this check must observe the lease before it can move the checkout.
1452
+ */
1453
+ const acquireSourceLease = async (expectedRevision, label = 'source reader') => {
1454
+ return acquireExactDevMirrorSourceLease({
1455
+ repoRoot,
1456
+ expectedRevision,
1457
+ label,
1458
+ withSyncLock: withSharedSyncLock,
1459
+ readCurrent: () => ({
1460
+ state: status.state,
1461
+ sourceRevision: status.commit,
1462
+ mirrorRoot,
1463
+ appRoot: mirrorAppRoot,
1464
+ }),
1465
+ });
1466
+ };
1467
+
1026
1468
  const stopPreview = async () => {
1027
1469
  const child = previewChild;
1028
1470
  previewChild = null;
@@ -1093,17 +1535,7 @@ export function pygmalionDevMirrorPlugin(options) {
1093
1535
  }
1094
1536
  if (!inventory.script && !inventory.command) return;
1095
1537
  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
- ];
1538
+ const args = resolveDevMirrorInventoryArgs(inventory, context);
1107
1539
  await run(command, args, { cwd: path.resolve(inventory.cwd ?? editorRoot) });
1108
1540
  };
1109
1541
 
@@ -1231,7 +1663,29 @@ export function pygmalionDevMirrorPlugin(options) {
1231
1663
  const { previous, commit, shortCommit } = mirrorState;
1232
1664
  await claimDevMirror(repoRoot, mirrorRoot, ownerToken);
1233
1665
  const dependenciesChanged = await syncDependencies();
1666
+ const runtimeInputsBefore = await fingerprintDevMirrorRuntimeInputs(
1667
+ mirrorAppRoot,
1668
+ inventoryRuntimeInputs,
1669
+ );
1234
1670
  await generateInventory(commit);
1671
+ const runtimeInputsAfter = await fingerprintDevMirrorRuntimeInputs(
1672
+ mirrorAppRoot,
1673
+ inventoryRuntimeInputs,
1674
+ );
1675
+ if (runtimeInputsAfter !== runtimeInputsBefore) {
1676
+ throw new Error(
1677
+ 'Inventory generation changed a declared inventory.runtimeInputs file.',
1678
+ );
1679
+ }
1680
+ const unexpectedChanges = await devMirrorUnexpectedChanges(
1681
+ mirrorRoot,
1682
+ managedPaths,
1683
+ );
1684
+ if (unexpectedChanges) {
1685
+ throw new Error(
1686
+ `Inventory generation changed a path outside inventory.outputs: ${unexpectedChanges.split('\n')[0]}`,
1687
+ );
1688
+ }
1235
1689
  await startPreview(dependenciesChanged, commit);
1236
1690
 
1237
1691
  revision += 1;
@@ -1271,6 +1725,7 @@ export function pygmalionDevMirrorPlugin(options) {
1271
1725
  // Pygmalion's own plugins compose through it.
1272
1726
  pygmalion: {
1273
1727
  acquireLease: (label) => acquireDevMirrorLease({ repoRoot, mirrorRoot, label }),
1728
+ acquireSourceLease,
1274
1729
  activeMirrorRoot: () => mirrorRoot,
1275
1730
  activeAppRoot: () => mirrorAppRoot,
1276
1731
  // A capture needs the checkout as much as a live frame does, but it asks