feat(FN-3723): harden isolated HOME cleanup and shell connection visibility
- Harden test isolation cleanup retries and allow-list behavior in scripts/test-changed.mjs - Expand test coverage for cleanup retry logic and allow-list handling in scripts/__tests__/test-changed.test.mjs - Add shell-native connection status plumbing and UI coverage across dashboard, desktop preload/types, and mobile typings - Add ShellConnectionStatus and mobile nav wiring plus related app/component test coverage - Document shell connection contracts and add FN-3568 diagnosis/recovery notes Fusion-Task-Id: FN-3723
This commit is contained in:
@@ -24,9 +24,12 @@ import {
|
||||
shouldRunIsolationGuard,
|
||||
defaultTestWorkerBudget,
|
||||
createIsolatedHomeEnv,
|
||||
cleanupIsolatedHomePath,
|
||||
knownIsolatedHomeBasenames,
|
||||
__setCleanupRmSyncForTests,
|
||||
} from "../test-changed.mjs";
|
||||
|
||||
import { mkdirSync, writeFileSync, mkdtempSync, rmSync } from "node:fs";
|
||||
import { mkdirSync, writeFileSync, mkdtempSync, rmSync, existsSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
@@ -673,3 +676,63 @@ test("createIsolatedHomeEnv: returns temp HOME/USERPROFILE pair without mutating
|
||||
|
||||
rmSync(isolatedHome, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("cleanupIsolatedHomePath: removes existing isolated HOME directory", () => {
|
||||
const homePath = mkdtempSync(path.join(tmpdir(), "fusion-test-home-root-cleanup-"));
|
||||
assert.equal(path.basename(homePath).startsWith("fusion-test-home-root-cleanup-"), true);
|
||||
|
||||
cleanupIsolatedHomePath(homePath);
|
||||
|
||||
assert.equal(existsSync(homePath), false);
|
||||
});
|
||||
|
||||
test("cleanupIsolatedHomePath: silently succeeds for ENOENT paths", () => {
|
||||
const missingPath = path.join(tmpdir(), `fusion-test-home-root-missing-${Date.now()}-${Math.random()}`);
|
||||
const warnings = [];
|
||||
const originalWarn = console.warn;
|
||||
console.warn = (msg) => warnings.push(String(msg));
|
||||
|
||||
try {
|
||||
cleanupIsolatedHomePath(missingPath);
|
||||
} finally {
|
||||
console.warn = originalWarn;
|
||||
}
|
||||
|
||||
assert.deepEqual(warnings, []);
|
||||
});
|
||||
|
||||
test("cleanupIsolatedHomePath: warns once after bounded retry failures", () => {
|
||||
const homePath = mkdtempSync(path.join(tmpdir(), "fusion-test-home-root-fail-"));
|
||||
const warnings = [];
|
||||
const originalWarn = console.warn;
|
||||
const error = Object.assign(new Error("simulated EBUSY"), { code: "EBUSY" });
|
||||
let calls = 0;
|
||||
|
||||
__setCleanupRmSyncForTests(() => {
|
||||
calls += 1;
|
||||
throw error;
|
||||
});
|
||||
console.warn = (msg) => warnings.push(String(msg));
|
||||
|
||||
try {
|
||||
cleanupIsolatedHomePath(homePath, 3, 0);
|
||||
} finally {
|
||||
__setCleanupRmSyncForTests(null);
|
||||
console.warn = originalWarn;
|
||||
rmSync(homePath, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
assert.equal(calls, 3);
|
||||
assert.equal(warnings.length, 1);
|
||||
assert.match(warnings[0], /failed to remove isolated HOME/);
|
||||
});
|
||||
|
||||
test("createIsolatedHomeEnv: records raw/realpath basenames in allow-list set", () => {
|
||||
const { isolatedHome } = createIsolatedHomeEnv({ PATH: process.env.PATH || "" });
|
||||
const base = path.basename(isolatedHome);
|
||||
|
||||
assert.equal(knownIsolatedHomeBasenames.has(base), true);
|
||||
assert.ok(knownIsolatedHomeBasenames.size >= 1);
|
||||
|
||||
cleanupIsolatedHomePath(isolatedHome);
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { readFileSync, readdirSync, writeFileSync, mkdirSync, renameSync, mkdtempSync, rmSync, realpathSync, globSync } from "node:fs";
|
||||
import { readFileSync, readdirSync, writeFileSync, mkdirSync, renameSync, mkdtempSync, rmSync, realpathSync, globSync, existsSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
@@ -696,26 +696,50 @@ const isolatedHomesToCleanup = new Set();
|
||||
// Basenames of every fusion-test-home-root-* dir this process has minted.
|
||||
// Passed to check-test-isolation.mjs via env so it allow-lists them
|
||||
// unconditionally, even if cleanup's rm silently failed.
|
||||
const knownIsolatedHomeBasenames = new Set();
|
||||
export const knownIsolatedHomeBasenames = new Set();
|
||||
|
||||
function cleanupIsolatedHomePath(homePath, retries = 3, delayMs = 200) {
|
||||
for (let attempt = 0; attempt <= retries; attempt++) {
|
||||
try {
|
||||
rmSync(homePath, { recursive: true, force: true });
|
||||
break;
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
if (attempt < retries) {
|
||||
// EBUSY on macOS: SQLite WAL still mmap'd or orphan child holding fd.
|
||||
// Spin briefly to give the OS time to release the handle.
|
||||
const end = Date.now() + delayMs;
|
||||
while (Date.now() < end) { /* busy-wait */ }
|
||||
} else {
|
||||
console.warn(`[test-changed] failed to remove isolated HOME ${homePath} after ${retries + 1} attempts: ${message}`);
|
||||
let cleanupRmSync = rmSync;
|
||||
|
||||
export function __setCleanupRmSyncForTests(nextRmSync) {
|
||||
cleanupRmSync = typeof nextRmSync === "function" ? nextRmSync : rmSync;
|
||||
}
|
||||
|
||||
function sleepMsSync(ms) {
|
||||
if (ms <= 0) return;
|
||||
spawnSync("sleep", [String(ms / 1000)], { stdio: "ignore" });
|
||||
}
|
||||
|
||||
/**
|
||||
* Retry isolated HOME cleanup synchronously to absorb transient EBUSY races
|
||||
* (common on macOS when Vitest workers still hold file descriptors briefly).
|
||||
* If cleanup still fails, check-test-isolation gets an allow-list of every
|
||||
* minted fusion-test-home-root-* basename to avoid false leak failures.
|
||||
*/
|
||||
export function cleanupIsolatedHomePath(homePath, retries = 3, delayMs = 75) {
|
||||
try {
|
||||
if (!existsSync(homePath)) return;
|
||||
|
||||
let lastError = null;
|
||||
for (let attempt = 1; attempt <= retries; attempt++) {
|
||||
try {
|
||||
cleanupRmSync(homePath, { recursive: true, force: true });
|
||||
return;
|
||||
} catch (err) {
|
||||
if (err && typeof err === "object" && "code" in err && err.code === "ENOENT") {
|
||||
return;
|
||||
}
|
||||
lastError = err;
|
||||
if (attempt < retries) {
|
||||
sleepMsSync(delayMs);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const message = lastError instanceof Error ? lastError.message : String(lastError);
|
||||
console.warn(`[test-changed] failed to remove isolated HOME ${homePath} after ${retries} attempts: ${message}`);
|
||||
} finally {
|
||||
isolatedHomesToCleanup.delete(homePath);
|
||||
}
|
||||
isolatedHomesToCleanup.delete(homePath);
|
||||
}
|
||||
|
||||
function cleanupIsolatedHomes() {
|
||||
|
||||
Reference in New Issue
Block a user