fix: handle ENOTEMPTY errors in auto-update with cleanup and retry (#632)
Co-authored-by: Letta <noreply@letta.com>
This commit is contained in:
18
src/index.ts
18
src/index.ts
@@ -364,9 +364,21 @@ async function main(): Promise<void> {
|
||||
|
||||
// Check for updates on startup (non-blocking)
|
||||
const { checkAndAutoUpdate } = await import("./updater/auto-update");
|
||||
checkAndAutoUpdate().catch(() => {
|
||||
// Silently ignore update failures
|
||||
});
|
||||
checkAndAutoUpdate()
|
||||
.then((result) => {
|
||||
// Surface ENOTEMPTY failures so users know how to fix
|
||||
if (result?.enotemptyFailed) {
|
||||
console.error(
|
||||
"\nAuto-update failed due to filesystem issue (ENOTEMPTY).",
|
||||
);
|
||||
console.error(
|
||||
"Fix: rm -rf $(npm prefix -g)/lib/node_modules/@letta-ai/letta-code && npm i -g @letta-ai/letta-code\n",
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// Silently ignore other update failures (network timeouts, etc.)
|
||||
});
|
||||
|
||||
// Clean up old overflow files (non-blocking, 24h retention)
|
||||
const { cleanupOldOverflowFiles } = await import("./tools/impl/overflow");
|
||||
|
||||
139
src/tests/updater/auto-update.test.ts
Normal file
139
src/tests/updater/auto-update.test.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
||||
import * as fs from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
|
||||
// We need to test the internal functions, so we'll recreate them here
|
||||
// In a real scenario, we'd export these for testing or use dependency injection
|
||||
|
||||
describe("auto-update ENOTEMPTY handling", () => {
|
||||
let testDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
// Create a temp directory for testing
|
||||
testDir = fs.mkdtempSync(path.join(os.tmpdir(), "letta-test-"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Clean up
|
||||
if (fs.existsSync(testDir)) {
|
||||
fs.rmSync(testDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe("cleanupOrphanedDirs logic", () => {
|
||||
test("removes directories starting with .letta-code-", async () => {
|
||||
// Create test directories
|
||||
const lettaAiDir = path.join(testDir, "lib/node_modules/@letta-ai");
|
||||
fs.mkdirSync(lettaAiDir, { recursive: true });
|
||||
|
||||
// Create orphaned temp dirs (should be removed)
|
||||
const orphan1 = path.join(lettaAiDir, ".letta-code-abc123");
|
||||
const orphan2 = path.join(lettaAiDir, ".letta-code-xyz789");
|
||||
fs.mkdirSync(orphan1);
|
||||
fs.mkdirSync(orphan2);
|
||||
|
||||
// Create legitimate dirs (should NOT be removed)
|
||||
const legitimate = path.join(lettaAiDir, "letta-code");
|
||||
const otherPackage = path.join(lettaAiDir, "other-package");
|
||||
fs.mkdirSync(legitimate);
|
||||
fs.mkdirSync(otherPackage);
|
||||
|
||||
// Simulate cleanup logic
|
||||
const { readdir, rm } = await import("node:fs/promises");
|
||||
const entries = await readdir(lettaAiDir);
|
||||
for (const entry of entries) {
|
||||
if (entry.startsWith(".letta-code-")) {
|
||||
await rm(path.join(lettaAiDir, entry), {
|
||||
recursive: true,
|
||||
force: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Verify
|
||||
expect(fs.existsSync(orphan1)).toBe(false);
|
||||
expect(fs.existsSync(orphan2)).toBe(false);
|
||||
expect(fs.existsSync(legitimate)).toBe(true);
|
||||
expect(fs.existsSync(otherPackage)).toBe(true);
|
||||
});
|
||||
|
||||
test("handles non-existent directory gracefully", async () => {
|
||||
const nonExistent = path.join(testDir, "does/not/exist");
|
||||
const { readdir } = await import("node:fs/promises");
|
||||
|
||||
// This should not throw
|
||||
let error: NodeJS.ErrnoException | null = null;
|
||||
try {
|
||||
await readdir(nonExistent);
|
||||
} catch (e) {
|
||||
error = e as NodeJS.ErrnoException;
|
||||
}
|
||||
|
||||
expect(error).not.toBeNull();
|
||||
expect(error?.code).toBe("ENOENT");
|
||||
});
|
||||
|
||||
test("handles empty directory", async () => {
|
||||
const emptyDir = path.join(testDir, "empty");
|
||||
fs.mkdirSync(emptyDir, { recursive: true });
|
||||
|
||||
const { readdir } = await import("node:fs/promises");
|
||||
const entries = await readdir(emptyDir);
|
||||
|
||||
expect(entries).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ENOTEMPTY error detection", () => {
|
||||
test("detects ENOTEMPTY in npm error message", () => {
|
||||
const npmError = `npm error code ENOTEMPTY
|
||||
npm error syscall rename
|
||||
npm error path /Users/user/.npm-global/lib/node_modules/@letta-ai/letta-code
|
||||
npm error dest /Users/user/.npm-global/lib/node_modules/@letta-ai/.letta-code-lnWEqMep
|
||||
npm error errno -66
|
||||
npm error ENOTEMPTY: directory not empty`;
|
||||
|
||||
expect(npmError.includes("ENOTEMPTY")).toBe(true);
|
||||
});
|
||||
|
||||
test("detects ENOTEMPTY in error.message", () => {
|
||||
const error = new Error(
|
||||
"Command failed: npm install -g @letta-ai/letta-code@latest\nnpm error ENOTEMPTY: directory not empty",
|
||||
);
|
||||
|
||||
expect(error.message.includes("ENOTEMPTY")).toBe(true);
|
||||
});
|
||||
|
||||
test("does not false-positive on other errors", () => {
|
||||
const networkError = "npm error ETIMEDOUT: network timeout";
|
||||
const permissionError = "npm error EACCES: permission denied";
|
||||
|
||||
expect(networkError.includes("ENOTEMPTY")).toBe(false);
|
||||
expect(permissionError.includes("ENOTEMPTY")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("npm global path detection", () => {
|
||||
test("path structure for cleanup is correct", () => {
|
||||
// Test that the path we construct is valid
|
||||
const globalPrefix = "/Users/test/.npm-global";
|
||||
const lettaAiDir = path.join(globalPrefix, "lib/node_modules/@letta-ai");
|
||||
|
||||
expect(lettaAiDir).toBe(
|
||||
"/Users/test/.npm-global/lib/node_modules/@letta-ai",
|
||||
);
|
||||
});
|
||||
|
||||
test("path structure works on Windows-style paths", () => {
|
||||
// Windows uses different separators but path.join handles it
|
||||
const globalPrefix = "C:\\Users\\test\\AppData\\Roaming\\npm";
|
||||
const lettaAiDir = path.join(globalPrefix, "lib/node_modules/@letta-ai");
|
||||
|
||||
// path.join normalizes separators for the current platform
|
||||
expect(lettaAiDir).toContain("lib");
|
||||
expect(lettaAiDir).toContain("node_modules");
|
||||
expect(lettaAiDir).toContain("@letta-ai");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,7 @@
|
||||
import { exec } from "node:child_process";
|
||||
import { realpathSync } from "node:fs";
|
||||
import { readdir, rm } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import { getVersion } from "../version";
|
||||
|
||||
@@ -83,7 +85,51 @@ async function checkForUpdate(): Promise<UpdateCheckResult> {
|
||||
};
|
||||
}
|
||||
|
||||
async function performUpdate(): Promise<{ success: boolean; error?: string }> {
|
||||
/**
|
||||
* Get the npm global prefix path (e.g., /Users/name/.npm-global or ~/.nvm/versions/node/v20/lib)
|
||||
*/
|
||||
async function getNpmGlobalPath(): Promise<string | null> {
|
||||
try {
|
||||
const { stdout } = await execAsync("npm prefix -g", { timeout: 5000 });
|
||||
return stdout.trim();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up orphaned temp directories left by interrupted npm installs.
|
||||
* These look like: .letta-code-lnWEqMep (npm's temp rename targets)
|
||||
*/
|
||||
async function cleanupOrphanedDirs(globalPath: string): Promise<void> {
|
||||
const lettaAiDir = join(globalPath, "lib/node_modules/@letta-ai");
|
||||
try {
|
||||
const entries = await readdir(lettaAiDir);
|
||||
for (const entry of entries) {
|
||||
// Match orphaned temp dirs like .letta-code-lnWEqMep
|
||||
if (entry.startsWith(".letta-code-")) {
|
||||
const orphanPath = join(lettaAiDir, entry);
|
||||
debugLog("Cleaning orphaned temp directory:", orphanPath);
|
||||
await rm(orphanPath, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Directory might not exist or not readable, ignore
|
||||
}
|
||||
}
|
||||
|
||||
async function performUpdate(): Promise<{
|
||||
success: boolean;
|
||||
error?: string;
|
||||
enotemptyFailed?: boolean;
|
||||
}> {
|
||||
// Pre-emptively clean up orphaned directories to prevent ENOTEMPTY errors
|
||||
const globalPath = await getNpmGlobalPath();
|
||||
if (globalPath) {
|
||||
debugLog("Pre-cleaning orphaned directories in:", globalPath);
|
||||
await cleanupOrphanedDirs(globalPath);
|
||||
}
|
||||
|
||||
try {
|
||||
debugLog("Running npm install -g @letta-ai/letta-code@latest...");
|
||||
await execAsync("npm install -g @letta-ai/letta-code@latest", {
|
||||
@@ -92,15 +138,49 @@ async function performUpdate(): Promise<{ success: boolean; error?: string }> {
|
||||
debugLog("Update completed successfully");
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||
|
||||
// If ENOTEMPTY still occurred (race condition or new orphans), try cleanup + retry once
|
||||
if (errorMsg.includes("ENOTEMPTY") && globalPath) {
|
||||
debugLog("ENOTEMPTY detected, attempting cleanup and retry...");
|
||||
await cleanupOrphanedDirs(globalPath);
|
||||
|
||||
try {
|
||||
await execAsync("npm install -g @letta-ai/letta-code@latest", {
|
||||
timeout: 60000,
|
||||
});
|
||||
debugLog("Update succeeded after cleanup retry");
|
||||
return { success: true };
|
||||
} catch (retryError) {
|
||||
const retryMsg =
|
||||
retryError instanceof Error ? retryError.message : String(retryError);
|
||||
debugLog("Update failed after retry:", retryMsg);
|
||||
|
||||
// If it's still ENOTEMPTY after retry, flag it for user notification
|
||||
if (retryMsg.includes("ENOTEMPTY")) {
|
||||
return {
|
||||
success: false,
|
||||
error: retryMsg,
|
||||
enotemptyFailed: true,
|
||||
};
|
||||
}
|
||||
return { success: false, error: retryMsg };
|
||||
}
|
||||
}
|
||||
|
||||
debugLog("Update failed:", error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
return { success: false, error: errorMsg };
|
||||
}
|
||||
}
|
||||
|
||||
export async function checkAndAutoUpdate() {
|
||||
export interface AutoUpdateResult {
|
||||
/** Whether an ENOTEMPTY error persisted after cleanup and retry */
|
||||
enotemptyFailed?: boolean;
|
||||
}
|
||||
|
||||
export async function checkAndAutoUpdate(): Promise<
|
||||
AutoUpdateResult | undefined
|
||||
> {
|
||||
debugLog("Auto-update check starting...");
|
||||
debugLog("isAutoUpdateEnabled:", isAutoUpdateEnabled());
|
||||
const runningLocally = isRunningLocally();
|
||||
@@ -119,7 +199,10 @@ export async function checkAndAutoUpdate() {
|
||||
const result = await checkForUpdate();
|
||||
|
||||
if (result.updateAvailable) {
|
||||
await performUpdate();
|
||||
const updateResult = await performUpdate();
|
||||
if (updateResult.enotemptyFailed) {
|
||||
return { enotemptyFailed: true };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user