@pygmalionjs/pygmalion 0.5.13 → 0.5.14

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.
@@ -25,6 +25,9 @@ const SHARED_LOCK_STALE_MS = 30_000;
25
25
  const STATUS_VERIFY_TTL_MS = 1_000;
26
26
  const LEASE_STALE_MS = 30_000;
27
27
  const LEASE_HEARTBEAT_MS = 2_000;
28
+ // A mirror younger than this is left alone even with no lease on it. Building one
29
+ // costs a checkout and a dependency install, so the reaper errs toward keeping.
30
+ const DEFAULT_MIRROR_GRACE_MS = 6 * 60 * 60 * 1_000;
28
31
  const LEASE_WAIT_TIMEOUT_MS = 180_000;
29
32
 
30
33
  export function resolveDevMirrorInventoryOutputRoot(inventory, mirrorAppRoot) {
@@ -285,6 +288,87 @@ export async function listSourceRefs(repoRoot, remote = 'origin') {
285
288
  });
286
289
  }
287
290
 
291
+ /**
292
+ * Removes mirror checkouts nobody is using any more.
293
+ *
294
+ * Every revision the editor is pointed at gets its own checkout, and until now
295
+ * nothing ever took one away — the only worktree verb in this module was `add`.
296
+ * A day of switching branches leaves a row of full checkouts (measured: 4 of
297
+ * them at ~500MB each, and a machine that had accumulated 35).
298
+ *
299
+ * Three things protect a checkout from being reaped, because "unused" is not
300
+ * something a single server can see on its own:
301
+ * the active one — this server is rendering from it right now
302
+ * a live lease — a capture is reading it, or another editor is serving it
303
+ * (the serve lease is what makes a concurrent editor visible)
304
+ * the grace window — a checkout younger than `graceMs`, so a mirror that was
305
+ * just built for a ref nobody has leased yet survives
306
+ *
307
+ * Removal is best effort: a checkout with local edits, or one git refuses for
308
+ * any other reason, is left alone and reported rather than forced away.
309
+ */
310
+ export async function reapDevMirrorWorktrees({
311
+ repoRoot,
312
+ mirrorBaseRoot,
313
+ keep = [],
314
+ graceMs = DEFAULT_MIRROR_GRACE_MS,
315
+ now = Date.now(),
316
+ }) {
317
+ // git reports worktrees by their real path, so a configured path that crosses a
318
+ // symlink (macOS `/tmp` and `/var` both do) would never match by string alone.
319
+ // The base itself is a naming prefix, not necessarily a directory that exists,
320
+ // so fall back to resolving it inside its parent — otherwise the prefix stays
321
+ // in `/var` while every listed worktree comes back as `/private/var`.
322
+ const real = async (target) => {
323
+ const resolved = path.resolve(target);
324
+ const direct = await fsp.realpath(resolved).catch(() => null);
325
+ if (direct) return direct;
326
+ const parent = await fsp.realpath(path.dirname(resolved)).catch(() => null);
327
+ return parent ? path.join(parent, path.basename(resolved)) : resolved;
328
+ };
329
+ const base = await real(mirrorBaseRoot);
330
+ const kept = new Set(await Promise.all(keep.filter(Boolean).map(real)));
331
+ const raw = await git(repoRoot, 'worktree', 'list', '--porcelain').catch(() => '');
332
+ const candidates = (
333
+ await Promise.all(
334
+ raw
335
+ .split('\n')
336
+ .filter((line) => line.startsWith('worktree '))
337
+ .map((line) => real(line.slice('worktree '.length).trim())),
338
+ )
339
+ )
340
+ // Only this project's mirrors. A sibling directory that merely starts with
341
+ // the same characters would need the separator to match too.
342
+ .filter((entry) => entry === base || entry.startsWith(`${base}-`))
343
+ .filter((entry) => !kept.has(entry));
344
+
345
+ const removed = [];
346
+ const skipped = [];
347
+ for (const candidate of candidates) {
348
+ const leases = await freshLeases(repoRoot, candidate);
349
+ if (leases.length > 0) {
350
+ skipped.push({ path: candidate, reason: 'leased', labels: leases.map((l) => l.label) });
351
+ continue;
352
+ }
353
+ const stat = await fsp.stat(candidate).catch(() => null);
354
+ if (stat && now - stat.mtimeMs < graceMs) {
355
+ skipped.push({ path: candidate, reason: 'recent' });
356
+ continue;
357
+ }
358
+ try {
359
+ await git(repoRoot, 'worktree', 'remove', '--force', candidate);
360
+ removed.push(candidate);
361
+ } catch (error) {
362
+ skipped.push({
363
+ path: candidate,
364
+ reason: 'remove-failed',
365
+ error: error instanceof Error ? error.message.split('\n')[0] : String(error),
366
+ });
367
+ }
368
+ }
369
+ return { removed, skipped };
370
+ }
371
+
288
372
  async function pygmalionStateDirectory(repoRoot) {
289
373
  const rawCommonGitDir = await git(repoRoot, 'rev-parse', '--git-common-dir');
290
374
  return path.join(path.resolve(repoRoot, rawCommonGitDir), 'pygmalion');
@@ -748,6 +832,28 @@ export function pygmalionDevMirrorPlugin(options) {
748
832
  const preferredPreviewPort =
749
833
  Number(process.env.PYGMALION_DEV_PREVIEW_PORT) || options.previewPort || DEFAULT_PREVIEW_PORT;
750
834
 
835
+ // Held for as long as this server renders from a mirror, so a reaper — ours or
836
+ // another editor's — can tell a live checkout from an abandoned one. Capture
837
+ // leases are short and come and go; this one spans the session.
838
+ let serveLease = null;
839
+ const holdServeLease = async (activeRoot) => {
840
+ if (serveLease?.mirrorRoot === activeRoot) return;
841
+ const previous = serveLease;
842
+ serveLease = null;
843
+ await previous?.lease.release().catch(() => undefined);
844
+ const lease = await acquireDevMirrorLease({
845
+ repoRoot,
846
+ mirrorRoot: activeRoot,
847
+ label: 'serve',
848
+ }).catch(() => null);
849
+ if (lease) serveLease = { mirrorRoot: activeRoot, lease };
850
+ };
851
+ const releaseServeLease = async () => {
852
+ const held = serveLease;
853
+ serveLease = null;
854
+ await held?.lease.release().catch(() => undefined);
855
+ };
856
+
751
857
  let previewChild = null;
752
858
  let previewPort = null;
753
859
  let syncPromise = null;
@@ -954,6 +1060,8 @@ export function pygmalionDevMirrorPlugin(options) {
954
1060
  env: {
955
1061
  ...process.env,
956
1062
  PYGMALION_PREVIEW_MODE: '1',
1063
+ // The child outlives a killed parent otherwise — it watches this pid.
1064
+ PYGMALION_PREVIEW_PARENT_PID: String(process.pid),
957
1065
  PYGMALION_APP_ROOT: mirrorAppRoot,
958
1066
  PYGMALION_VITE_CONFIG: viteConfig,
959
1067
  PYGMALION_PREVIEW_BASE: `${prefix}/`,
@@ -1003,6 +1111,8 @@ export function pygmalionDevMirrorPlugin(options) {
1003
1111
  mirrorOwnerScope = ownerToken;
1004
1112
  applyMirrorPathsForRef(ref);
1005
1113
  }
1114
+ // After the path is settled, not before — the claim above can move it.
1115
+ await holdServeLease(mirrorRoot);
1006
1116
  status = { ...status, state: 'syncing', error: null, warning: null };
1007
1117
  let warning = null;
1008
1118
  try {
@@ -1069,9 +1179,40 @@ export function pygmalionDevMirrorPlugin(options) {
1069
1179
  configureServer(server) {
1070
1180
  server.httpServer?.once('close', () => {
1071
1181
  void stopPreview();
1182
+ void releaseServeLease();
1183
+ });
1184
+ // A killed editor never reaches the close hook, and the preview is a
1185
+ // detached-enough child that it keeps serving a checkout nobody owns —
1186
+ // measured: three of them still listening on a directory already removed.
1187
+ // Signals cover the ordinary exits; the child watches its parent for the
1188
+ // rest (see PYGMALION_PREVIEW_PARENT_PID in dev-view.vite.mjs).
1189
+ for (const signal of ['SIGINT', 'SIGTERM']) {
1190
+ process.once(signal, () => {
1191
+ void stopPreview();
1192
+ void releaseServeLease();
1193
+ });
1194
+ }
1195
+ process.once('exit', () => {
1196
+ previewChild?.kill('SIGTERM');
1072
1197
  });
1073
1198
  // Synchronizes once when the editor server runs. Screen mount and manual refresh requests reuse the same Promise.
1074
- void syncMirror();
1199
+ void syncMirror().then(() =>
1200
+ reapDevMirrorWorktrees({
1201
+ repoRoot,
1202
+ mirrorBaseRoot,
1203
+ keep: [mirrorRoot],
1204
+ })
1205
+ .then(({ removed }) => {
1206
+ if (removed.length > 0) {
1207
+ console.log(
1208
+ `[pygmalion] released ${removed.length} unused dev screen checkout(s): ${removed
1209
+ .map((entry) => path.basename(entry))
1210
+ .join(', ')}`,
1211
+ );
1212
+ }
1213
+ })
1214
+ .catch(() => undefined),
1215
+ );
1075
1216
 
1076
1217
  server.middlewares.use(async (req, res, next) => {
1077
1218
  const url = new URL(req.url ?? '/', 'http://localhost');
@@ -23,6 +23,43 @@ const previewCacheDir = process.env.PYGMALION_VITE_CACHE_DIR
23
23
  : undefined;
24
24
  export const PREVIEW_RUNTIME_DEDUPE = ['react', 'react-dom'];
25
25
 
26
+ const PARENT_WATCH_INTERVAL_MS = 5_000;
27
+
28
+ /**
29
+ * Exits when the editor that spawned this preview is gone.
30
+ *
31
+ * The parent stops the preview on close and on the usual signals, but a killed
32
+ * editor (`pkill`, a crash) never runs any of that, and this process keeps
33
+ * serving a checkout nobody owns — measured: three previews still listening on a
34
+ * mirror directory that had already been removed. Signal 0 only asks whether the
35
+ * pid is still there; it sends nothing.
36
+ */
37
+ export function watchParentProcess({
38
+ parentPid = Number(process.env.PYGMALION_PREVIEW_PARENT_PID) || null,
39
+ intervalMs = PARENT_WATCH_INTERVAL_MS,
40
+ isAlive = (pid) => {
41
+ try {
42
+ process.kill(pid, 0);
43
+ return true;
44
+ } catch (error) {
45
+ // A live process owned by another user answers EPERM, not ESRCH.
46
+ return error?.code === 'EPERM';
47
+ }
48
+ },
49
+ onOrphaned = () => process.exit(0),
50
+ } = {}) {
51
+ if (!parentPid) return null;
52
+ const timer = setInterval(() => {
53
+ if (isAlive(parentPid)) return;
54
+ clearInterval(timer);
55
+ onOrphaned();
56
+ }, intervalMs);
57
+ timer.unref();
58
+ return timer;
59
+ }
60
+
61
+ watchParentProcess();
62
+
26
63
  function previewRuntimePlugin() {
27
64
  return {
28
65
  name: 'pygmalion-dev-preview-runtime',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pygmalionjs/pygmalion",
3
- "version": "0.5.13",
3
+ "version": "0.5.14",
4
4
  "description": "Code-backed DOM design sandbox and visual QA editor",
5
5
  "license": "UNLICENSED",
6
6
  "publishConfig": {