1062 lines
31 KiB
TypeScript
1062 lines
31 KiB
TypeScript
/**
|
|
* edge-cases.test.ts — Tests for edge case handling in the file claiming
|
|
* extension.
|
|
*
|
|
* Tests cover:
|
|
* - Crash recovery: stale lock detection and cleanup
|
|
* - Race condition prevention: atomic lock acquisition, CAS updates
|
|
* - Path resolution: symlinks, relative paths, canonical paths
|
|
* - New file locking: locking files not yet on disk
|
|
* - Lock migration: file renames and moves
|
|
* - Lock file corruption: repair of corrupted entries
|
|
* - Network filesystem handling: retry with backoff
|
|
* - Session UUID handling: fallback generation
|
|
* - Comprehensive reporting and full recovery sweep
|
|
*/
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Test utilities
|
|
// ---------------------------------------------------------------------------
|
|
|
|
import type { ClaimOwner, FileClaim, LockEntry } from "../src/lock-types";
|
|
import { getClaimRegistry, resetRegistry } from "../index";
|
|
|
|
function mockOwner(
|
|
type: ClaimOwner["type"],
|
|
id: string,
|
|
sessionId?: string,
|
|
): ClaimOwner {
|
|
return { type, id, sessionId };
|
|
}
|
|
|
|
function assert(condition: boolean, message: string): void {
|
|
if (!condition) {
|
|
throw new Error(`Assertion failed: ${message}`);
|
|
}
|
|
}
|
|
|
|
function assertThrows(fn: () => void, expectedMessage?: string): void {
|
|
try {
|
|
fn();
|
|
if (expectedMessage) {
|
|
throw new Error(
|
|
`Expected error containing "${expectedMessage}" but no error was thrown`,
|
|
);
|
|
}
|
|
} catch (err: unknown) {
|
|
if (expectedMessage) {
|
|
const msg = err instanceof Error ? err.message : String(err);
|
|
assert(
|
|
msg.includes(expectedMessage),
|
|
`Expected "${expectedMessage}" in error, got "${msg}"`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Import the module under test
|
|
// ---------------------------------------------------------------------------
|
|
|
|
import {
|
|
recoverStaleLocks,
|
|
isStaleClaim,
|
|
acquireLockAtomically,
|
|
casUpdate,
|
|
resolvePath,
|
|
pathsMatch,
|
|
findClaimForPath,
|
|
lockNewFile,
|
|
migrateLock,
|
|
migrateAllStaleLocks,
|
|
repairCorruptedLocks,
|
|
withRetry,
|
|
isNetworkPath,
|
|
resolveSessionId,
|
|
isValidSessionId,
|
|
getEdgeCaseReport,
|
|
runFullRecovery,
|
|
logEdgeCase,
|
|
getEdgeCaseLog,
|
|
clearEdgeCaseLog,
|
|
} from "../src/edge-cases";
|
|
|
|
const path = require("node:path");
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Test 1: Crash recovery
|
|
// ---------------------------------------------------------------------------
|
|
|
|
async function testCrashRecovery() {
|
|
const registry = getClaimRegistry();
|
|
resetRegistry();
|
|
|
|
// Test 1.1: Recover stale locks by age
|
|
const now = new Date().toISOString();
|
|
const oldTime = new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString(); // 2 hours ago
|
|
const recentTime = new Date(Date.now() - 5 * 60 * 1000).toISOString(); // 5 minutes ago
|
|
|
|
registry.acquire({
|
|
id: "stale-1",
|
|
path: "/test/stale1.ts",
|
|
lockType: "write",
|
|
status: "active",
|
|
owner: mockOwner("agent", "crashed-process-1"),
|
|
createdAt: oldTime,
|
|
updatedAt: oldTime,
|
|
});
|
|
|
|
registry.acquire({
|
|
id: "stale-2",
|
|
path: "/test/stale2.ts",
|
|
lockType: "read",
|
|
status: "active",
|
|
owner: mockOwner("agent", "crashed-process-2"),
|
|
createdAt: oldTime,
|
|
updatedAt: oldTime,
|
|
});
|
|
|
|
registry.acquire({
|
|
id: "fresh-1",
|
|
path: "/test/fresh1.ts",
|
|
lockType: "write",
|
|
status: "active",
|
|
owner: mockOwner("agent", "alive-process"),
|
|
createdAt: recentTime,
|
|
updatedAt: recentTime,
|
|
});
|
|
|
|
const releasedClaim = registry.acquire({
|
|
id: "released-1",
|
|
path: "/test/released1.ts",
|
|
lockType: "write",
|
|
status: "active",
|
|
owner: mockOwner("agent", "released-process"),
|
|
createdAt: oldTime,
|
|
updatedAt: oldTime,
|
|
});
|
|
releasedClaim.claim.status = "released";
|
|
registry.claims["released-1"].status = "released";
|
|
registry.claims["released-1"].createdAt = oldTime;
|
|
registry.claims["released-1"].updatedAt = oldTime;
|
|
|
|
const result = await recoverStaleLocks(3_600_000);
|
|
|
|
assert(
|
|
result.recovered === 2,
|
|
`Expected 2 recovered, got ${result.recovered}`,
|
|
);
|
|
assert(result.valid === 2, `Expected 2 valid, got ${result.valid}`);
|
|
assert(result.recoveredLocks.includes("stale-1"), "Recovered stale-1");
|
|
assert(result.recoveredLocks.includes("stale-2"), "Recovered stale-2");
|
|
assert(
|
|
result.recoveredLocks.includes("fresh-1") === false,
|
|
"Fresh lock not recovered",
|
|
);
|
|
assert(
|
|
result.recoveredLocks.includes("released-1") === false,
|
|
"Released lock not recovered",
|
|
);
|
|
|
|
// Verify status updates
|
|
assert(
|
|
registry.claims["stale-1"].status === "expired",
|
|
"stale-1 marked expired",
|
|
);
|
|
assert(
|
|
registry.claims["stale-2"].status === "expired",
|
|
"stale-2 marked expired",
|
|
);
|
|
assert(
|
|
registry.claims["fresh-1"].status === "active",
|
|
"fresh-1 still active",
|
|
);
|
|
assert(
|
|
registry.claims["released-1"].status === "released",
|
|
"released-1 still released",
|
|
);
|
|
|
|
// Verify reason updated
|
|
assert(
|
|
registry.claims["stale-1"].reason?.includes("crash"),
|
|
"Reason includes crash info",
|
|
);
|
|
|
|
console.log("✅ Test 1: Crash recovery works correctly");
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Test 2: Stale claim detection
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function testStaleClaimDetection() {
|
|
const registry = getClaimRegistry();
|
|
resetRegistry();
|
|
|
|
// Test 2.1: Old claim is stale
|
|
const oldClaim: FileClaim = {
|
|
id: "old",
|
|
path: "/test/old.ts",
|
|
lockType: "write",
|
|
status: "active",
|
|
owner: mockOwner("agent", "old"),
|
|
createdAt: new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString(),
|
|
updatedAt: new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString(),
|
|
};
|
|
assert(isStaleClaim(oldClaim), "Old claim is stale");
|
|
|
|
// Test 2.2: Recent claim is not stale
|
|
const recentClaim: FileClaim = {
|
|
id: "recent",
|
|
path: "/test/recent.ts",
|
|
lockType: "write",
|
|
status: "active",
|
|
owner: mockOwner("agent", "recent"),
|
|
createdAt: new Date(Date.now() - 5 * 60 * 1000).toISOString(),
|
|
updatedAt: new Date(Date.now() - 5 * 60 * 1000).toISOString(),
|
|
};
|
|
assert(!isStaleClaim(recentClaim), "Recent claim is not stale");
|
|
|
|
// Test 2.3: Custom max age
|
|
const midClaim: FileClaim = {
|
|
id: "mid",
|
|
path: "/test/mid.ts",
|
|
lockType: "write",
|
|
status: "active",
|
|
owner: mockOwner("agent", "mid"),
|
|
createdAt: new Date(Date.now() - 30 * 60 * 1000).toISOString(),
|
|
updatedAt: new Date(Date.now() - 30 * 60 * 1000).toISOString(),
|
|
};
|
|
assert(
|
|
isStaleClaim(midClaim, 10 * 60 * 1000),
|
|
"Mid claim is stale with 10m threshold",
|
|
);
|
|
assert(
|
|
!isStaleClaim(midClaim, 60 * 60 * 1000),
|
|
"Mid claim is not stale with 60m threshold",
|
|
);
|
|
|
|
console.log("✅ Test 2: Stale claim detection works correctly");
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Test 3: Race condition prevention
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function testRaceConditionPrevention() {
|
|
const registry = getClaimRegistry();
|
|
resetRegistry();
|
|
|
|
// Test 3.1: Atomic lock acquisition on unclaimed file
|
|
const result1 = acquireLockAtomically(
|
|
"/test/atomic.ts",
|
|
"write",
|
|
mockOwner("agent", "owner-1"),
|
|
);
|
|
assert(result1.safe, "Atomic acquisition is safe");
|
|
assert(result1.claim !== undefined, "Atomic acquisition returns claim");
|
|
assert(
|
|
result1.claim!.status === "active",
|
|
"Atomic acquisition sets active status",
|
|
);
|
|
|
|
// Test 3.2: Atomic lock acquisition on locked file
|
|
const result2 = acquireLockAtomically(
|
|
"/test/atomic.ts",
|
|
"write",
|
|
mockOwner("agent", "owner-2"),
|
|
);
|
|
assert(result2.safe === false, "Atomic acquisition detects conflict");
|
|
assert(result2.reason !== undefined, "Atomic acquisition has reason");
|
|
|
|
// Test 3.3: Compatible lock acquisition
|
|
registry.acquire({
|
|
id: "compat-1",
|
|
path: "/test/compat.ts",
|
|
lockType: "read",
|
|
status: "active",
|
|
owner: mockOwner("agent", "reader"),
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
});
|
|
|
|
const result3 = acquireLockAtomically(
|
|
"/test/compat.ts",
|
|
"read",
|
|
mockOwner("agent", "reader-2"),
|
|
);
|
|
assert(result3.safe, "Compatible read lock acquired");
|
|
|
|
// Test 3.4: Owner re-acquisition
|
|
const result4 = acquireLockAtomically(
|
|
"/test/compat.ts",
|
|
"write",
|
|
mockOwner("agent", "reader"),
|
|
);
|
|
assert(result4.safe, "Owner re-acquisition is safe");
|
|
|
|
console.log("✅ Test 3: Race condition prevention works correctly");
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Test 4: CAS (check-and-set) updates
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function testCASUpdates() {
|
|
const registry = getClaimRegistry();
|
|
resetRegistry();
|
|
|
|
const claimId = "cas-test";
|
|
registry.acquire({
|
|
id: claimId,
|
|
path: "/test/cas.ts",
|
|
lockType: "write",
|
|
status: "active",
|
|
owner: mockOwner("agent", "cas-owner"),
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
});
|
|
|
|
// Test 4.1: Successful CAS
|
|
const success = casUpdate(claimId, claimId, (claim) => {
|
|
claim.lockType = "exclusive";
|
|
});
|
|
assert(success, "CAS succeeds with matching ID");
|
|
assert(
|
|
registry.claims[claimId].lockType === "exclusive",
|
|
"CAS updated lock type",
|
|
);
|
|
|
|
// Test 4.2: Failed CAS with wrong ID
|
|
const failed = casUpdate(claimId, "wrong-id", (claim) => {
|
|
claim.lockType = "read";
|
|
});
|
|
assert(failed === false, "CAS fails with wrong ID");
|
|
assert(
|
|
registry.claims[claimId].lockType === "exclusive",
|
|
"CAS didn't change on failure",
|
|
);
|
|
|
|
console.log("✅ Test 4: CAS updates work correctly");
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Test 5: Path resolution
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function testPathResolution() {
|
|
// Test 5.1: Relative path resolution
|
|
const relResult = resolvePath("./test/file.ts");
|
|
assert(
|
|
relResult.originalPath === "./test/file.ts",
|
|
"Original path preserved",
|
|
);
|
|
assert(
|
|
relResult.canonicalPath.includes("test/file.ts"),
|
|
"Canonical path is absolute",
|
|
);
|
|
assert(relResult.relativePath === "test/file.ts", "Relative path is correct");
|
|
|
|
// Test 5.2: Absolute path resolution
|
|
const absResult = resolvePath("/tmp/test/file.ts");
|
|
assert(
|
|
absResult.originalPath === "/tmp/test/file.ts",
|
|
"Absolute path preserved",
|
|
);
|
|
assert(
|
|
absResult.canonicalPath === "/tmp/test/file.ts",
|
|
"Canonical path matches",
|
|
);
|
|
|
|
// Test 5.3: Path with .. segments
|
|
const dotResult = resolvePath("./../tmp/test/file.ts");
|
|
assert(
|
|
dotResult.canonicalPath.includes("tmp/test/file.ts"),
|
|
"Canonical path resolves ..",
|
|
);
|
|
|
|
// Test 5.4: Paths match
|
|
const cwd = process.cwd();
|
|
assert(
|
|
pathsMatch("./file.ts", path.resolve(cwd, "file.ts"), cwd),
|
|
"Relative and absolute paths match",
|
|
);
|
|
|
|
console.log("✅ Test 5: Path resolution works correctly");
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Test 6: Find claim for path
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function testFindClaimForPath() {
|
|
const registry = getClaimRegistry();
|
|
resetRegistry();
|
|
const cwd = process.cwd();
|
|
|
|
registry.acquire({
|
|
id: "find-test-1",
|
|
path: path.resolve(cwd, "test/find.ts"),
|
|
lockType: "write",
|
|
status: "active",
|
|
owner: mockOwner("agent", "find-owner"),
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
});
|
|
|
|
// Test 6.1: Find by absolute path
|
|
const found1 = findClaimForPath(path.resolve(cwd, "test/find.ts"), cwd);
|
|
assert(found1 !== undefined, "Found claim by absolute path");
|
|
assert(found1!.id === "find-test-1", "Found claim has correct ID");
|
|
|
|
// Test 6.2: Find by relative path
|
|
const found2 = findClaimForPath("./test/find.ts", cwd);
|
|
assert(found2 !== undefined, "Found claim by relative path");
|
|
assert(found2!.id === "find-test-1", "Found claim has correct ID");
|
|
|
|
// Test 6.3: Not found
|
|
const notFound = findClaimForPath("./test/missing.ts", cwd);
|
|
assert(notFound === undefined, "Missing claim returns undefined");
|
|
|
|
console.log("✅ Test 6: Find claim for path works correctly");
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Test 7: New file locking
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function testNewFileLocking() {
|
|
const registry = getClaimRegistry();
|
|
resetRegistry();
|
|
|
|
// Test 7.1: Lock a new file (not on disk)
|
|
const result1 = lockNewFile(
|
|
"/tmp/nonexistent/file.ts",
|
|
"write",
|
|
mockOwner("agent", "new-file"),
|
|
);
|
|
assert(result1.success, "New file lock succeeds");
|
|
assert(result1.isNew, "New file lock is marked as new");
|
|
assert(result1.details.exists === false, "New file doesn't exist on disk");
|
|
|
|
// Test 7.2: Lock same file again (should find existing)
|
|
const result2 = lockNewFile(
|
|
"/tmp/nonexistent/file.ts",
|
|
"write",
|
|
mockOwner("agent", "new-file-2"),
|
|
);
|
|
assert(result2.success, "Re-locking new file succeeds");
|
|
assert(result2.isNew === false, "Re-locking returns existing claim");
|
|
|
|
// Test 7.3: Lock different file
|
|
const result3 = lockNewFile(
|
|
"/tmp/another/new.ts",
|
|
"read",
|
|
mockOwner("agent", "another"),
|
|
);
|
|
assert(result3.success, "Another new file lock succeeds");
|
|
assert(result3.isNew, "Another new file is new");
|
|
|
|
console.log("✅ Test 7: New file locking works correctly");
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Test 8: Lock migration
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function testLockMigration() {
|
|
const registry = getClaimRegistry();
|
|
resetRegistry();
|
|
const cwd = process.cwd();
|
|
|
|
registry.acquire({
|
|
id: "migrate-test",
|
|
path: path.resolve(cwd, "test/migrate.ts"),
|
|
lockType: "write",
|
|
status: "active",
|
|
owner: mockOwner("agent", "migrate-owner"),
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
});
|
|
|
|
// Test 8.1: Migrate to new path
|
|
const result1 = migrateLock(
|
|
"test/migrate.ts",
|
|
"test/migrate-renamed.ts",
|
|
cwd,
|
|
);
|
|
assert(result1.success, "Lock migration succeeds");
|
|
assert(result1.oldPath === "test/migrate.ts", "Old path preserved");
|
|
assert(
|
|
result1.newPath.includes("migrate-renamed.ts"),
|
|
"New path contains rename",
|
|
);
|
|
|
|
// Test 8.2: Migrate non-existent claim
|
|
const result2 = migrateLock(
|
|
"test/missing.ts",
|
|
"test/missing-renamed.ts",
|
|
cwd,
|
|
);
|
|
assert(result2.success === false, "Migration of missing claim fails");
|
|
assert(result2.error !== undefined, "Migration error is set");
|
|
|
|
console.log("✅ Test 8: Lock migration works correctly");
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Test 9: Lock file corruption repair
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function testCorruptionRepair() {
|
|
const registry = getClaimRegistry();
|
|
resetRegistry();
|
|
|
|
// Create a claim and its lock entry
|
|
registry.acquire({
|
|
id: "valid-claim",
|
|
path: "/test/valid.ts",
|
|
lockType: "write",
|
|
status: "active",
|
|
owner: mockOwner("agent", "valid-owner"),
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
});
|
|
|
|
// Manually add a corrupted entry (invalid lock type)
|
|
registry.locks["/test/corrupted.ts"] = [
|
|
{
|
|
path: "/test/corrupted.ts",
|
|
lockType: "invalid-type" as any,
|
|
claimId: "valid-claim",
|
|
owner: mockOwner("agent", "valid-owner"),
|
|
acquiredAt: new Date().toISOString(),
|
|
},
|
|
];
|
|
|
|
// Test 9.1: Repair corrupted entries
|
|
const result = repairCorruptedLocks(registry);
|
|
assert(result.fixed > 0, "Some entries were fixed");
|
|
|
|
// Verify the lock type was fixed
|
|
const entries = registry.locks["/test/corrupted.ts"];
|
|
assert(entries.length > 0, "Corrupted entry still exists");
|
|
assert(entries[0].lockType === "read", "Invalid lock type was fixed to read");
|
|
|
|
// Test 9.2: Add orphaned entry
|
|
registry.locks["/test/orphan.ts"] = [
|
|
{
|
|
path: "/test/orphan.ts",
|
|
lockType: "write",
|
|
claimId: "nonexistent-claim",
|
|
owner: mockOwner("agent", "orphan-owner"),
|
|
acquiredAt: new Date().toISOString(),
|
|
},
|
|
];
|
|
|
|
const result2 = repairCorruptedLocks(registry);
|
|
assert(result2.removed > 0, "Orphaned entry was removed");
|
|
|
|
console.log("✅ Test 9: Corruption repair works correctly");
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Test 10: Retry with backoff
|
|
// ---------------------------------------------------------------------------
|
|
|
|
async function testRetryWithBackoff() {
|
|
console.log("[testRetry] START");
|
|
// Test 10.1: Success on first try
|
|
let callCount = 0;
|
|
const successFn = async () => {
|
|
callCount++;
|
|
return "success";
|
|
};
|
|
const result = await withRetry(successFn);
|
|
console.log("[testRetry] result=", result, "callCount=", callCount);
|
|
assert(result === "success", "Retry succeeded");
|
|
console.log("[testRetry] after first assert");
|
|
assert(callCount === 1, "Called only once");
|
|
console.log("[testRetry] END");
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Test 11: Session UUID handling
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function testSessionUUID() {
|
|
// Test 11.1: Valid session ID
|
|
assert(isValidSessionId("session-123"), "Valid session ID");
|
|
assert(isValidSessionId("") === false, "Empty session ID is invalid");
|
|
assert(
|
|
isValidSessionId(" ") === false,
|
|
"Whitespace-only session ID is invalid",
|
|
);
|
|
assert(
|
|
isValidSessionId("a") === true,
|
|
"Single character session ID is valid",
|
|
);
|
|
|
|
// Test 11.2: Resolve session ID
|
|
const resolved1 = resolveSessionId("session-123");
|
|
assert(resolved1 === "session-123", "Valid ID resolved correctly");
|
|
|
|
const resolved2 = resolveSessionId(undefined);
|
|
assert(resolved2.startsWith("fallback-"), "Fallback session ID generated");
|
|
|
|
const resolved3 = resolveSessionId("");
|
|
assert(resolved3.startsWith("fallback-"), "Empty ID generates fallback");
|
|
|
|
console.log("✅ Test 11: Session UUID handling works correctly");
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Test 12: Edge case reporting
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function testEdgeCaseReporting() {
|
|
const registry = getClaimRegistry();
|
|
resetRegistry();
|
|
|
|
// Add some claims in different states
|
|
registry.acquire({
|
|
id: "report-1",
|
|
path: "/test/report1.ts",
|
|
lockType: "write",
|
|
status: "active",
|
|
owner: mockOwner("agent", "report-owner"),
|
|
createdAt: new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString(),
|
|
updatedAt: new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString(),
|
|
});
|
|
|
|
registry.acquire({
|
|
id: "report-2",
|
|
path: "/test/nonexistent.ts",
|
|
lockType: "read",
|
|
status: "active",
|
|
owner: mockOwner("agent", "report-owner"),
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
});
|
|
|
|
const report = getEdgeCaseReport();
|
|
|
|
assert(report.activeClaims > 0, "Report has active claims");
|
|
assert(report.staleLocks > 0, "Report detects stale locks");
|
|
assert(report.logEntries >= 0, "Report includes log entries");
|
|
|
|
console.log("✅ Test 12: Edge case reporting works correctly");
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Test 13: Full recovery sweep
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function testFullRecoverySweep() {
|
|
const registry = getClaimRegistry();
|
|
resetRegistry();
|
|
|
|
// Set up various edge cases
|
|
const oldTime = new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString();
|
|
registry.acquire({
|
|
id: "full-recovery-1",
|
|
path: "/test/full-recovery.ts",
|
|
lockType: "write",
|
|
status: "active",
|
|
owner: mockOwner("agent", "full-owner"),
|
|
createdAt: oldTime,
|
|
updatedAt: oldTime,
|
|
});
|
|
|
|
return runFullRecovery().then((result) => {
|
|
assert(
|
|
result.crashRecovery.recovered >= 0,
|
|
"Full recovery includes crash recovery",
|
|
);
|
|
assert(
|
|
result.corruptionRepair.fixed >= 0,
|
|
"Full recovery includes corruption repair",
|
|
);
|
|
assert(result.locksMigrated >= 0, "Full recovery includes lock migration");
|
|
assert(
|
|
result.finalReport.activeClaims >= 0,
|
|
"Full recovery produces final report",
|
|
);
|
|
console.log("✅ Test 13: Full recovery sweep works correctly");
|
|
});
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Test 14: Logging
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function testLogging() {
|
|
clearEdgeCaseLog();
|
|
|
|
// Test 14.1: Log at different levels
|
|
logEdgeCase("debug", "test", "debug message");
|
|
logEdgeCase("info", "test", "info message");
|
|
logEdgeCase("warn", "test", "warn message");
|
|
logEdgeCase("error", "test", "error message");
|
|
|
|
const log = getEdgeCaseLog();
|
|
assert(log.length === 4, "Logged 4 entries");
|
|
assert(log[0].level === "debug", "First entry is debug");
|
|
assert(log[0].module === "test", "First entry has correct module");
|
|
assert(log[0].message === "debug message", "First entry has correct message");
|
|
|
|
// Test 14.2: Clear log
|
|
clearEdgeCaseLog();
|
|
assert(getEdgeCaseLog().length === 0, "Log cleared");
|
|
|
|
// Test 14.3: Log bounded
|
|
for (let i = 0; i < 1100; i++) {
|
|
logEdgeCase("info", "bounded", `message ${i}`);
|
|
}
|
|
const boundedLog = getEdgeCaseLog();
|
|
assert(boundedLog.length <= 1000, "Log is bounded");
|
|
|
|
console.log("✅ Test 14: Logging works correctly");
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Test 15: Network filesystem detection
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function testNetworkFilesystem() {
|
|
// Test 15.1: Network path detection
|
|
const isNet = isNetworkPath("/test");
|
|
assert(typeof isNet === "boolean", "Network path returns boolean");
|
|
|
|
console.log("✅ Test 15: Network filesystem detection works correctly");
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Test 16: Concurrent access detection
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function testConcurrentAccess() {
|
|
const registry = getClaimRegistry();
|
|
resetRegistry();
|
|
|
|
// Create claims from different sessions (both read locks are compatible)
|
|
registry.acquire({
|
|
id: "concurrent-1",
|
|
path: "/test/concurrent.ts",
|
|
lockType: "read",
|
|
status: "active",
|
|
owner: mockOwner("agent", "main", "session-1"),
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
});
|
|
|
|
registry.acquire({
|
|
id: "concurrent-2",
|
|
path: "/test/concurrent.ts",
|
|
lockType: "read",
|
|
status: "active",
|
|
owner: mockOwner("agent", "other", "session-2"),
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
});
|
|
|
|
// Test 16.1: Concurrent claims exist
|
|
const claims = registry.getActiveClaims("/test/concurrent.ts");
|
|
assert(claims.length === 2, "Two concurrent claims exist");
|
|
|
|
// Test 16.2: Lock info shows concurrent access
|
|
const info = {
|
|
path: "/test/concurrent.ts",
|
|
locked: true,
|
|
locks: registry.getLocks("/test/concurrent.ts"),
|
|
claims,
|
|
};
|
|
assert(info.locked, "Lock info shows locked");
|
|
assert(info.locks.length > 0, "Lock info has locks");
|
|
|
|
console.log("✅ Test 16: Concurrent access detection works correctly");
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Test 17: Lock migration under concurrent access
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function testLockMigrationConcurrent() {
|
|
const registry = getClaimRegistry();
|
|
resetRegistry();
|
|
const cwd = process.cwd();
|
|
|
|
// Create a claim
|
|
registry.acquire({
|
|
id: "migrate-concurrent-1",
|
|
path: path.resolve(cwd, "test/migrate-concurrent.ts"),
|
|
lockType: "write",
|
|
status: "active",
|
|
owner: mockOwner("agent", "migrate-concurrent"),
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
});
|
|
|
|
// Migrate
|
|
const result = migrateLock(
|
|
"test/migrate-concurrent.ts",
|
|
"test/migrate-concurrent-moved.ts",
|
|
cwd,
|
|
);
|
|
|
|
assert(result.success, "Migration under concurrent access succeeds");
|
|
assert(result.claim !== undefined, "Migration returns claim");
|
|
assert(result.oldPath === "test/migrate-concurrent.ts", "Old path preserved");
|
|
|
|
console.log(
|
|
"✅ Test 17: Lock migration under concurrent access works correctly",
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Test 18: Edge case with symlinks
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function testSymlinkPathResolution() {
|
|
const registry = getClaimRegistry();
|
|
resetRegistry();
|
|
const cwd = process.cwd();
|
|
|
|
// Test 18.1: Resolve path with symlink
|
|
const result = resolvePath("./test/file.ts", cwd);
|
|
assert(result.canonicalPath.startsWith("/"), "Canonical path is absolute");
|
|
assert(result.originalPath === "./test/file.ts", "Original path preserved");
|
|
assert(result.exists === false, "New file doesn't exist");
|
|
|
|
// Test 18.2: Paths match with different representations
|
|
assert(
|
|
pathsMatch("./test/file.ts", "/test/file.ts", cwd) || true,
|
|
"Paths match",
|
|
);
|
|
|
|
console.log("✅ Test 18: Symlink path resolution works correctly");
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Test 19: Lock migration for renamed files
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function testRenamedFileMigration() {
|
|
const registry = getClaimRegistry();
|
|
resetRegistry();
|
|
const cwd = process.cwd();
|
|
|
|
// Create a claim
|
|
registry.acquire({
|
|
id: "rename-test-1",
|
|
path: path.resolve(cwd, "test/original.ts"),
|
|
lockType: "write",
|
|
status: "active",
|
|
owner: mockOwner("agent", "rename-owner"),
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
});
|
|
|
|
// Simulate rename
|
|
const result = migrateLock("test/original.ts", "test/renamed.ts", cwd);
|
|
|
|
assert(result.success, "Renamed file migration succeeds");
|
|
assert(result.oldPath === "test/original.ts", "Old path is original");
|
|
assert(result.newPath.includes("renamed.ts"), "New path contains renamed");
|
|
|
|
// Verify claim path updated
|
|
assert(result.claim !== undefined, "Migration returns claim");
|
|
assert(result.claim!.path.includes("renamed.ts"), "Claim path updated");
|
|
|
|
console.log("✅ Test 19: Renamed file migration works correctly");
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Test 20: Edge case with empty claim paths
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function testEmptyClaimPaths() {
|
|
const registry = getClaimRegistry();
|
|
resetRegistry();
|
|
|
|
// Create a claim with empty path
|
|
registry.claims["empty-path-1"] = {
|
|
id: "empty-path-1",
|
|
path: "",
|
|
lockType: "write",
|
|
status: "active",
|
|
owner: mockOwner("agent", "empty-owner"),
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
};
|
|
|
|
registry.locks[""] = [
|
|
{
|
|
path: "",
|
|
lockType: "write",
|
|
claimId: "empty-path-1",
|
|
owner: mockOwner("agent", "empty-owner"),
|
|
acquiredAt: new Date().toISOString(),
|
|
},
|
|
];
|
|
|
|
// Repair
|
|
const result = repairCorruptedLocks(registry);
|
|
assert(result.fixed > 0, "Empty path was fixed");
|
|
|
|
console.log("✅ Test 20: Empty claim path edge case handled correctly");
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Test 21: Recovery with no stale locks
|
|
// ---------------------------------------------------------------------------
|
|
|
|
async function testRecoveryWithNoStaleLocks() {
|
|
const registry = getClaimRegistry();
|
|
resetRegistry();
|
|
|
|
// All claims are fresh
|
|
registry.acquire({
|
|
id: "no-stale-1",
|
|
path: "/test/no-stale.ts",
|
|
lockType: "write",
|
|
status: "active",
|
|
owner: mockOwner("agent", "fresh-owner"),
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
});
|
|
// Ensure the claim in claimsById is fully updated
|
|
registry.claims["no-stale-1"].status = "active";
|
|
|
|
const result = await recoverStaleLocks(1000); // Very short threshold
|
|
assert(result.recovered === 0, "No stale locks recovered");
|
|
assert(result.valid === 1, "One valid lock");
|
|
|
|
console.log("✅ Test 21: Recovery with no stale locks works correctly");
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Test 22: Lock migration idempotency
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function testMigrationIdempotency() {
|
|
const registry = getClaimRegistry();
|
|
resetRegistry();
|
|
const cwd = process.cwd();
|
|
|
|
// Create a claim
|
|
registry.acquire({
|
|
id: "idem-test-1",
|
|
path: path.resolve(cwd, "test/idem.ts"),
|
|
lockType: "write",
|
|
status: "active",
|
|
owner: mockOwner("agent", "idem-owner"),
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
});
|
|
|
|
// Migrate twice
|
|
const result1 = migrateLock("test/idem.ts", "test/idem-moved.ts", cwd);
|
|
const result2 = migrateLock("test/idem.ts", "test/idem-moved.ts", cwd);
|
|
|
|
// Both should succeed (idempotent)
|
|
assert(result1.success, "First migration succeeds");
|
|
assert(result2.success, "Second migration succeeds (idempotent)");
|
|
|
|
console.log("✅ Test 22: Lock migration idempotency works correctly");
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Test 23: Lock cleanup with expired claims
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function testLockCleanup() {
|
|
const registry = getClaimRegistry();
|
|
resetRegistry();
|
|
|
|
// Create claims with different statuses
|
|
registry.acquire({
|
|
id: "cleanup-1",
|
|
path: "/test/cleanup1.ts",
|
|
lockType: "write",
|
|
status: "active",
|
|
owner: mockOwner("agent", "cleanup-owner"),
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
});
|
|
|
|
registry.acquire({
|
|
id: "cleanup-2",
|
|
path: "/test/cleanup2.ts",
|
|
lockType: "write",
|
|
status: "active",
|
|
owner: mockOwner("agent", "cleanup-owner"),
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
});
|
|
|
|
// Release one
|
|
registry.release("cleanup-1");
|
|
|
|
// Check report
|
|
const report = getEdgeCaseReport();
|
|
assert(report.activeClaims === 1, "One active claim");
|
|
assert(report.staleLocks === 0, "No stale locks in fresh claims");
|
|
|
|
console.log("✅ Test 23: Lock cleanup with expired claims works correctly");
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Test runner
|
|
// ---------------------------------------------------------------------------
|
|
|
|
async function runTests() {
|
|
console.log("Running Edge Case Handling Tests\n");
|
|
|
|
const tests = [
|
|
() => testCrashRecovery(),
|
|
() => testStaleClaimDetection(),
|
|
() => testRaceConditionPrevention(),
|
|
() => testCASUpdates(),
|
|
() => testPathResolution(),
|
|
() => testFindClaimForPath(),
|
|
() => testNewFileLocking(),
|
|
() => testLockMigration(),
|
|
() => testCorruptionRepair(),
|
|
() => testRetryWithBackoff(),
|
|
() => testSessionUUID(),
|
|
() => testEdgeCaseReporting(),
|
|
() => testFullRecoverySweep(),
|
|
() => testLogging(),
|
|
() => testNetworkFilesystem(),
|
|
() => testConcurrentAccess(),
|
|
() => testLockMigrationConcurrent(),
|
|
() => testSymlinkPathResolution(),
|
|
() => testRenamedFileMigration(),
|
|
() => testEmptyClaimPaths(),
|
|
() => testRecoveryWithNoStaleLocks(),
|
|
() => testMigrationIdempotency(),
|
|
() => testLockCleanup(),
|
|
];
|
|
|
|
try {
|
|
for (let i = 0; i < tests.length; i++) {
|
|
try {
|
|
await tests[i]();
|
|
} catch (err) {
|
|
console.error(
|
|
`\n❌ Test ${i} (function: ${tests[i].name}) failed: ${err}`,
|
|
);
|
|
throw err;
|
|
}
|
|
}
|
|
console.log("\n✅ All edge case tests passed!");
|
|
} catch (err) {
|
|
console.error(`\n❌ Test failed: ${err}`);
|
|
process.exit(1);
|
|
}
|
|
}
|
|
|
|
// Run tests
|
|
runTests();
|