pm2 save when the process list changes, and a check that does not lie about symlinks
the owner asked whether anything runs `pm2 save` when a sidecar is added or removed. nothing did. PM2 resurrects from ~/.pm2/dump.pm2, written only by `pm2 save`, so the dump was whatever a human last happened to save. today it was FIVE DAYS old: 13 processes saved against 26 running. a reboot would have silently lost every sidecar installed since — bitcoin-wallet, desktop, gitea, invoices, iris, jellyfin, minesweeper, music, photos, soulseek, transcribe, transmission, vault. thirteen features gone, machine healthy, nothing saying why. saved after start AND after delete. removal matters as much: a dump that keeps resurrecting a deleted sidecar brings it back after a reboot as a crash loop for code no longer on the machine. a RESTART does not save — the same processes are running and the dump is already right. best effort and never fatal: a failed save leaves the estate correct and only the dump stale, which is where things already were. failing an install over bookkeeping would be the worse trade. separately, plugins:check filtered directories on the DIRENT's isDirectory(), which answers false for a symlink to a directory. platform/plugins is symlinked now, so the check would have reported "0 plugin repositories, all well-formed" — a green check over nothing, which is worse than a red one. it stats the path instead, the way discover.ts already does. dump saved once by hand as well, since five days of drift was already there: 26 saved, 26 running, no difference. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
This commit is contained in:
@@ -25,7 +25,7 @@
|
||||
that running the check can never be the thing that changes your tree.
|
||||
*/
|
||||
|
||||
import { readdirSync, existsSync } from 'node:fs';
|
||||
import { readdirSync, existsSync, statSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
/*
|
||||
@@ -52,7 +52,19 @@ type Problem = { plugin: string; what: string; fix?: () => void };
|
||||
const problems: Problem[] = [];
|
||||
|
||||
const dirs = readdirSync(PLUGINS_DIR, { withFileTypes: true })
|
||||
.filter((e) => e.isDirectory() && !e.name.startsWith('.') && e.name !== 'node_modules')
|
||||
// `statSync().isDirectory()`, not the dirent's — a Dirent for a SYMLINK to a directory answers false to
|
||||
// `isDirectory()` and true to `isSymbolicLink()`, so filtering on the dirent silently skips every
|
||||
// symlinked plugin. `platform/plugins/` is symlinks into the workspace clones since 2026-08-27, which
|
||||
// would have made this report "0 plugin repositories, all well-formed" — a green check over nothing.
|
||||
// `discover.ts` already does it this way, at line 239, for the same reason.
|
||||
.filter((e) => !e.name.startsWith('.') && e.name !== 'node_modules')
|
||||
.filter((e) => {
|
||||
try {
|
||||
return statSync(join(PLUGINS_DIR, e.name)).isDirectory();
|
||||
} catch {
|
||||
return false; // a broken symlink, which the checks below have nothing useful to say about
|
||||
}
|
||||
})
|
||||
.map((e) => e.name)
|
||||
.filter((name) => existsSync(join(PLUGINS_DIR, name, '.git')))
|
||||
.sort();
|
||||
|
||||
@@ -63,6 +63,8 @@ export async function startProcess(name: string, cwd: string): Promise<Pm2Result
|
||||
|
||||
const { code, out } = await pm2(args, cwd);
|
||||
if (code !== 0) return { ok: false, error: out || `pm2 ${args.join(' ')} exited ${code}` };
|
||||
// The list changed, so the dump has to. See `saveProcessList`.
|
||||
await saveProcessList(cwd);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
@@ -105,9 +107,34 @@ export async function deleteProcess(name: string, cwd: string): Promise<Pm2Resul
|
||||
if (code !== 0 && !/not found|doesn't exist/i.test(out)) {
|
||||
return { ok: false, error: out || `pm2 delete ${name} exited ${code}` };
|
||||
}
|
||||
// Saved on REMOVAL too, or the dump keeps resurrecting a sidecar whose plugin is gone — which comes
|
||||
// back after a reboot as a crash-looping process for code that is no longer on the machine.
|
||||
await saveProcessList(cwd);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist the process list, so a reboot brings back what is actually installed.
|
||||
*
|
||||
* ── Why this is not optional ──
|
||||
*
|
||||
* PM2 resurrects from `~/.pm2/dump.pm2`, written only by `pm2 save`. Nothing in the plugin system ever
|
||||
* called it, so the dump was whatever a human last happened to save — on 2026-08-27 it was five days old
|
||||
* and held 13 processes while 26 were running. A reboot would have silently lost every sidecar installed
|
||||
* since, and the symptom is the worst kind: the machine comes up, the platform is fine, and thirteen
|
||||
* features are simply gone with nothing saying why.
|
||||
*
|
||||
* ── Best effort, and never fatal ──
|
||||
*
|
||||
* A failed save leaves the estate correct and only the dump stale, which is exactly where things were
|
||||
* before this existed. Failing an install over it would trade a working plugin for a bookkeeping error.
|
||||
* It is logged rather than returned for the same reason.
|
||||
*/
|
||||
export async function saveProcessList(cwd: string): Promise<void> {
|
||||
const { code, out } = await pm2(['save'], cwd);
|
||||
if (code !== 0) console.error(`[pm2] could not save the process list — a reboot may not restore it: ${out}`);
|
||||
}
|
||||
|
||||
/** 'online' | 'stopped' | 'errored' | … , or null when PM2 has never heard of it. */
|
||||
export async function processStatus(name: string, cwd: string): Promise<string | null> {
|
||||
const { code, out } = await pm2(['jlist'], cwd);
|
||||
|
||||
Reference in New Issue
Block a user