fix: handle ENOTEMPTY errors in auto-update with cleanup and retry (#632)

Co-authored-by: Letta <noreply@letta.com>
This commit is contained in:
Charles Packer
2026-01-21 21:57:56 -08:00
committed by GitHub
parent b8b6e72d11
commit 388cbd6e25
3 changed files with 244 additions and 10 deletions

View File

@@ -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");

View 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");
});
});
});

View File

@@ -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 };
}
}
}