fix(launch): scope daemon prune to the daemons container
smokeTestDaemonBroker mkdtemp'd its runtime dir directly under os.tmpdir(), and the broker's startup sweep pruneDeadDaemonRuntimeDirs reclaims path.dirname(runtimeDir). On a default session that is /tmp, so --smoke-test recursively deleted every aged sibling with no live broker/clients (tmux/ssh sockets, editor state, build trees) while still exiting 0. - client.ts: keep the smoke broker's project and runtime dirs under a single private mkdtemp parent the process owns. - presence.ts: refuse any prune root that is not the daemons container and only prune entries named like a 16-hex daemon scope key, so a relocated runtime dir can never rm -rf unrelated neighbours. Fixes #8721
This commit is contained in:
@@ -2,6 +2,10 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed `omp --smoke-test` recursively deleting unrelated directories in `os.tmpdir()` (tmux/ssh sockets, editor state, build trees). The smoke broker now keeps its runtime dir under a private parent, and the dead-scope reclaim refuses any root that is not the `daemons` container and only prunes entries named like a 16-hex daemon scope key ([#8721](https://github.com/can1357/oh-my-pi/issues/8721)).
|
||||
|
||||
## [17.3.5] - 2026-08-16
|
||||
|
||||
### Added
|
||||
|
||||
@@ -516,8 +516,14 @@ export async function closeDaemonClients(): Promise<void> {
|
||||
|
||||
/** Exercise worker-host broker startup and authenticated RPC for distribution smoke tests. */
|
||||
export async function smokeTestDaemonBroker(): Promise<void> {
|
||||
const projectDir = await fs.mkdtemp(path.join(os.tmpdir(), "omp-daemon-smoke-project-"));
|
||||
const runtimeDir = await fs.mkdtemp(path.join(os.tmpdir(), "omp-daemon-smoke-run-"));
|
||||
// Keep the broker's runtime dir under a private parent this process owns, so
|
||||
// the broker's dead-scope sweep (pruneDeadDaemonRuntimeDirs, fired on startup)
|
||||
// can only ever reclaim siblings inside it — never unrelated neighbours in
|
||||
// os.tmpdir() such as tmux/ssh sockets or build trees (issue #8721).
|
||||
const smokeRoot = await fs.mkdtemp(path.join(os.tmpdir(), "omp-daemon-smoke-"));
|
||||
const projectDir = path.join(smokeRoot, "project");
|
||||
const runtimeDir = path.join(smokeRoot, "run");
|
||||
await fs.mkdir(projectDir, { recursive: true });
|
||||
const client = await createDaemonBrokerClient(projectDir, { runtimeDir, idleGraceMs: 5_000 });
|
||||
try {
|
||||
const ping = await client.request({ op: "ping" });
|
||||
@@ -525,7 +531,6 @@ export async function smokeTestDaemonBroker(): Promise<void> {
|
||||
await client.request({ op: "shutdown" });
|
||||
} finally {
|
||||
client.close();
|
||||
await fs.rm(projectDir, { recursive: true, force: true });
|
||||
await fs.rm(runtimeDir, { recursive: true, force: true });
|
||||
await fs.rm(smokeRoot, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,19 @@ import { daemonRuntimeDir } from "./paths";
|
||||
|
||||
const CLIENTS_DIR = "clients";
|
||||
const BROKER_PID_FILE = "broker.pid";
|
||||
const GLOBAL_DAEMON_DIR = "global";
|
||||
/**
|
||||
* Basename of the container holding per-project daemon scopes
|
||||
* (`<state>/run/daemons`). {@link pruneDeadDaemonRuntimeDirs} refuses to sweep
|
||||
* any other root so a runtime dir passed from outside the state tree cannot
|
||||
* turn the reclaim into an rm -rf of unrelated neighbours (issue #8721).
|
||||
*/
|
||||
const DAEMONS_DIR = "daemons";
|
||||
/**
|
||||
* Name shape of a project daemon scope: the 16-hex wyhash of the project dir
|
||||
* produced by `getDaemonRuntimeDir`. Only entries matching this are pruned,
|
||||
* which excludes the machine-global `global` container and any foreign dir.
|
||||
*/
|
||||
const DAEMON_SCOPE_KEY = /^[0-9a-f]{16}$/;
|
||||
/**
|
||||
* Grace before a dead daemon runtime dir becomes prune-eligible. Guards against
|
||||
* deleting a scope whose owning omp process is mid-startup (token written, broker
|
||||
@@ -118,11 +130,14 @@ async function hasLiveDaemonBroker(runtimeDir: string): Promise<boolean> {
|
||||
* Best-effort and non-throwing: a scope is deleted only when its `broker.pid`
|
||||
* is absent/dead, no live client presence remains, and it has been untouched
|
||||
* for {@link DAEMON_RUNTIME_STALE_GRACE_MS}. The caller's own `currentRuntimeDir`
|
||||
* and the machine-global daemon container are always skipped.
|
||||
* is always skipped, and the sweep runs only inside the {@link DAEMONS_DIR}
|
||||
* container over entries named like a {@link DAEMON_SCOPE_KEY} — so a runtime
|
||||
* dir relocated elsewhere (e.g. the smoke test under `os.tmpdir()`) never
|
||||
* reclaims unrelated neighbours (issue #8721).
|
||||
*/
|
||||
export async function pruneDeadDaemonRuntimeDirs(currentRuntimeDir: string): Promise<void> {
|
||||
const root = path.dirname(currentRuntimeDir);
|
||||
if (path.basename(root) === GLOBAL_DAEMON_DIR) return;
|
||||
if (path.basename(root) !== DAEMONS_DIR) return;
|
||||
const current = path.resolve(currentRuntimeDir);
|
||||
let entries: Dirent[];
|
||||
try {
|
||||
@@ -138,7 +153,7 @@ export async function pruneDeadDaemonRuntimeDirs(currentRuntimeDir: string): Pro
|
||||
}
|
||||
const now = Date.now();
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory() || entry.name === GLOBAL_DAEMON_DIR) continue;
|
||||
if (!entry.isDirectory() || !DAEMON_SCOPE_KEY.test(entry.name)) continue;
|
||||
const dir = path.join(root, entry.name);
|
||||
if (path.resolve(dir) === current) continue;
|
||||
try {
|
||||
|
||||
@@ -41,11 +41,11 @@ describe("pruneDeadDaemonRuntimeDirs", () => {
|
||||
const daemons = path.join(tempDir.path(), "run", "daemons");
|
||||
await fs.mkdir(daemons, { recursive: true });
|
||||
|
||||
const current = await scope(daemons, "current000000000", { pid: "dead", stale: true });
|
||||
await scope(daemons, "deadstale0000000", { pid: "dead", stale: true });
|
||||
await scope(daemons, "livebroker000000", { pid: process.pid, stale: true });
|
||||
await scope(daemons, "liveclient000000", { clients: [process.pid], stale: true });
|
||||
await scope(daemons, "deadfresh0000000", { pid: "dead" });
|
||||
const current = await scope(daemons, "aaaaaaaaaaaaaaaa", { pid: "dead", stale: true });
|
||||
await scope(daemons, "bbbbbbbbbbbbbbbb", { pid: "dead", stale: true });
|
||||
await scope(daemons, "cccccccccccccccc", { pid: process.pid, stale: true });
|
||||
await scope(daemons, "dddddddddddddddd", { clients: [process.pid], stale: true });
|
||||
await scope(daemons, "eeeeeeeeeeeeeeee", { pid: "dead" });
|
||||
// Machine-global daemon container must never be swept as a project scope.
|
||||
await fs.mkdir(path.join(daemons, "global", "some-service"), { recursive: true });
|
||||
await fs.utimes(path.join(daemons, "global"), STALE, STALE);
|
||||
@@ -53,12 +53,12 @@ describe("pruneDeadDaemonRuntimeDirs", () => {
|
||||
await pruneDeadDaemonRuntimeDirs(current);
|
||||
|
||||
const remaining = new Set(await fs.readdir(daemons));
|
||||
expect(remaining.has("deadstale0000000")).toBe(false); // pruned
|
||||
expect(remaining.has("current000000000")).toBe(true); // never prunes itself
|
||||
expect(remaining.has("livebroker000000")).toBe(true); // live broker
|
||||
expect(remaining.has("liveclient000000")).toBe(true); // live client presence
|
||||
expect(remaining.has("deadfresh0000000")).toBe(true); // within stale grace
|
||||
expect(remaining.has("global")).toBe(true); // global container skipped
|
||||
expect(remaining.has("bbbbbbbbbbbbbbbb")).toBe(false); // pruned
|
||||
expect(remaining.has("aaaaaaaaaaaaaaaa")).toBe(true); // never prunes itself
|
||||
expect(remaining.has("cccccccccccccccc")).toBe(true); // live broker
|
||||
expect(remaining.has("dddddddddddddddd")).toBe(true); // live client presence
|
||||
expect(remaining.has("eeeeeeeeeeeeeeee")).toBe(true); // within stale grace
|
||||
expect(remaining.has("global")).toBe(true); // non-scope name skipped
|
||||
});
|
||||
|
||||
it("does not sweep sibling machine-global service runtimes", async () => {
|
||||
@@ -72,6 +72,26 @@ describe("pruneDeadDaemonRuntimeDirs", () => {
|
||||
expect(await fs.exists(sibling)).toBe(true);
|
||||
});
|
||||
|
||||
it("never sweeps outside the daemons container when a runtime dir is relocated (issue #8721)", async () => {
|
||||
using tempDir = TempDir.createSync("@omp-daemon-prune-tmpdir-");
|
||||
// Simulate the smoke test relocating its runtime dir directly under a
|
||||
// shared temp root full of unrelated, aged directories.
|
||||
const fakeTmp = tempDir.path();
|
||||
for (const name of ["tmux-1000", "ssh-XVn1oP", "my-build-tree"]) {
|
||||
await fs.mkdir(path.join(fakeTmp, name, "src"), { recursive: true });
|
||||
await fs.utimes(path.join(fakeTmp, name), STALE, STALE);
|
||||
}
|
||||
const runtimeDir = path.join(fakeTmp, "omp-daemon-smoke-run-xxxx");
|
||||
await fs.mkdir(runtimeDir, { recursive: true });
|
||||
|
||||
await pruneDeadDaemonRuntimeDirs(runtimeDir);
|
||||
|
||||
const remaining = new Set(await fs.readdir(fakeTmp));
|
||||
expect(remaining.has("tmux-1000")).toBe(true);
|
||||
expect(remaining.has("ssh-XVn1oP")).toBe(true);
|
||||
expect(remaining.has("my-build-tree")).toBe(true);
|
||||
});
|
||||
|
||||
it("does nothing when the runtime root does not exist", async () => {
|
||||
using tempDir = TempDir.createSync("@omp-daemon-prune-missing-");
|
||||
const current = path.join(tempDir.path(), "run", "daemons", "hash0000000000000");
|
||||
|
||||
Reference in New Issue
Block a user