@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.
- package/node/dev-mirror.mjs +142 -1
- package/node/dev-view.vite.mjs +37 -0
- package/package.json +1 -1
package/node/dev-mirror.mjs
CHANGED
|
@@ -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');
|
package/node/dev-view.vite.mjs
CHANGED
|
@@ -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',
|