@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.
@@ -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,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 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
- };
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