@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.
- package/node/dev-mirror.mjs +160 -2
- 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) {
|
|
@@ -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');
|
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',
|