@pygmalionjs/pygmalion 0.5.12 → 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) {
@@ -253,13 +256,30 @@ export async function listSourceRefs(repoRoot, remote = 'origin') {
253
256
  .filter((parts) => parts.length === 3 && parts[0])
254
257
  .map(([name, commit, fullName]) => ({ name, commit, fullName }));
255
258
  };
256
- const locals = (await read('refs/heads')).map((item) => ({ ...item, kind: 'local' }));
257
259
  const remotes = (await read(`refs/remotes/${remote}`))
258
260
  // `%(refname:short)` may abbreviate refs/remotes/origin/HEAD to just
259
261
  // `origin`, so the full ref is the only reliable way to remove the
260
262
  // symbolic remote default from the branch picker.
261
263
  .filter((item) => !item.fullName.endsWith('/HEAD'))
262
264
  .map((item) => ({ ...item, kind: 'remote' }));
265
+ // `commit` is what SELECTING this entry would render, not what the listed ref
266
+ // happens to point at. Choosing a branch name repoints the mirror at that
267
+ // branch on the remote (syncDevMirrorWorktree fetches and resolves
268
+ // `<remote>/<name>`), so a local branch has to report its remote tip. Reading
269
+ // refs/heads instead made an editor name an OLDER commit as the update target
270
+ // whenever the local ref lagged behind what the mirror had already fetched —
271
+ // `dev@a0d5fdbf → 3e8cca5b`, an invitation to go backwards.
272
+ const remoteTipPrefix = `refs/remotes/${remote}/`;
273
+ const remoteTips = new Map(
274
+ remotes.map((item) => [item.fullName.slice(remoteTipPrefix.length), item.commit]),
275
+ );
276
+ const locals = (await read('refs/heads')).map((item) => ({
277
+ ...item,
278
+ // A branch the remote does not have resolves locally (the fetch fails and
279
+ // becomes a warning), so its own tip is the honest answer there.
280
+ commit: remoteTips.get(item.name) ?? item.commit,
281
+ kind: 'local',
282
+ }));
263
283
  const seen = new Set();
264
284
  return [...locals, ...remotes].filter((item) => {
265
285
  if (seen.has(item.name)) return false;
@@ -268,6 +288,87 @@ export async function listSourceRefs(repoRoot, remote = 'origin') {
268
288
  });
269
289
  }
270
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
+
271
372
  async function pygmalionStateDirectory(repoRoot) {
272
373
  const rawCommonGitDir = await git(repoRoot, 'rev-parse', '--git-common-dir');
273
374
  return path.join(path.resolve(repoRoot, rawCommonGitDir), 'pygmalion');
@@ -731,6 +832,28 @@ export function pygmalionDevMirrorPlugin(options) {
731
832
  const preferredPreviewPort =
732
833
  Number(process.env.PYGMALION_DEV_PREVIEW_PORT) || options.previewPort || DEFAULT_PREVIEW_PORT;
733
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
+
734
857
  let previewChild = null;
735
858
  let previewPort = null;
736
859
  let syncPromise = null;
@@ -937,6 +1060,8 @@ export function pygmalionDevMirrorPlugin(options) {
937
1060
  env: {
938
1061
  ...process.env,
939
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),
940
1065
  PYGMALION_APP_ROOT: mirrorAppRoot,
941
1066
  PYGMALION_VITE_CONFIG: viteConfig,
942
1067
  PYGMALION_PREVIEW_BASE: `${prefix}/`,
@@ -986,6 +1111,8 @@ export function pygmalionDevMirrorPlugin(options) {
986
1111
  mirrorOwnerScope = ownerToken;
987
1112
  applyMirrorPathsForRef(ref);
988
1113
  }
1114
+ // After the path is settled, not before — the claim above can move it.
1115
+ await holdServeLease(mirrorRoot);
989
1116
  status = { ...status, state: 'syncing', error: null, warning: null };
990
1117
  let warning = null;
991
1118
  try {
@@ -1052,9 +1179,40 @@ export function pygmalionDevMirrorPlugin(options) {
1052
1179
  configureServer(server) {
1053
1180
  server.httpServer?.once('close', () => {
1054
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');
1055
1197
  });
1056
1198
  // Synchronizes once when the editor server runs. Screen mount and manual refresh requests reuse the same Promise.
1057
- 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
+ );
1058
1216
 
1059
1217
  server.middlewares.use(async (req, res, next) => {
1060
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.12",
3
+ "version": "0.5.14",
4
4
  "description": "Code-backed DOM design sandbox and visual QA editor",
5
5
  "license": "UNLICENSED",
6
6
  "publishConfig": {