fix(cli/windows): avoid EBUSY/EPERM on global upgrades by keeping native .exe in per-user cache; launcher prefers cache\n\n- Postinstall: on Windows, install binary to %LocalAppData%\just-every\code\<version> and do not leave a copy in node_modules.\n- Launcher: run from cache when available; mirror into node_modules only on Unix.\n- Outcome: global npm upgrades no longer fail when the CLI is running.

This commit is contained in:
James Peter
2025-09-11 10:06:49 +10:00
parent b7269b4456
commit faa712d374
3 changed files with 111 additions and 57 deletions
+38 -14
View File
@@ -177,9 +177,12 @@ const tryBootstrapBinary = async () => {
if (existsSync(cachePath)) {
const v = validateBinary(cachePath);
if (v.ok) {
copyFileSync(cachePath, binaryPath);
if (platform !== "win32") chmodSync(binaryPath, 0o755);
return existsSync(binaryPath);
// Prefer running directly from cache; mirror into node_modules on Unix
if (platform !== "win32") {
copyFileSync(cachePath, binaryPath);
try { chmodSync(binaryPath, 0o755); } catch {}
}
return true;
}
}
@@ -202,11 +205,13 @@ const tryBootstrapBinary = async () => {
const pkgDir = path.dirname(pkgJson);
const src = path.join(pkgDir, "bin", `code-${targetTriple}${platform === "win32" ? ".exe" : ""}`);
if (existsSync(src)) {
copyFileSync(src, binaryPath);
if (platform !== "win32") chmodSync(binaryPath, 0o755);
// refresh cache
try { copyFileSync(binaryPath, cachePath); } catch {}
return existsSync(binaryPath);
// Always ensure cache has the binary; on Unix mirror into node_modules
copyFileSync(src, cachePath);
if (platform !== "win32") {
copyFileSync(cachePath, binaryPath);
try { chmodSync(binaryPath, 0o755); } catch {}
}
return true;
}
} catch { /* ignore and fall back */ }
}
@@ -239,10 +244,19 @@ const tryBootstrapBinary = async () => {
try { unlinkSync(tmp); } catch {}
}
}
const v = validateBinary(binaryPath);
// On Windows, prefer cache and avoid leaving the executable in node_modules
if (platform === "win32") {
try {
copyFileSync(binaryPath, cachePath);
} catch {}
try { unlinkSync(binaryPath); } catch {}
} else {
try { copyFileSync(binaryPath, cachePath); } catch {}
}
const v = validateBinary(platform === "win32" ? cachePath : binaryPath);
if (!v.ok) throw new Error(`invalid binary (${v.reason})`);
if (platform !== "win32") chmodSync(binaryPath, 0o755);
try { copyFileSync(binaryPath, cachePath); } catch {}
if (platform !== "win32") try { chmodSync(binaryPath, 0o755); } catch {}
return true;
})
.catch((_e) => false);
@@ -262,9 +276,19 @@ if (!existsSync(binaryPath) && !existsSync(legacyBinaryPath)) {
}
}
// Fall back to legacy name if primary is still missing
if (!existsSync(binaryPath) && existsSync(legacyBinaryPath)) {
binaryPath = legacyBinaryPath;
// Prefer cached binary when available
try {
const pkg = JSON.parse(readFileSync(path.join(__dirname, "..", "package.json"), "utf8"));
const version = pkg.version;
const cached = getCachedBinaryPath(version);
const v = existsSync(cached) ? validateBinary(cached) : { ok: false };
if (v.ok) {
binaryPath = cached;
} else if (!existsSync(binaryPath) && existsSync(legacyBinaryPath)) {
binaryPath = legacyBinaryPath;
}
} catch {
// ignore
}
// Check if binary exists and try to fix permissions if needed
+44 -40
View File
@@ -227,30 +227,23 @@ async function main() {
const localPath = join(binDir, binaryName);
const cachePath = getCachedBinaryPath(version, targetTriple, isWindows);
// Skip if already exists and has correct permissions
if (existsSync(localPath)) {
// Always try to fix permissions on Unix-like systems
if (!isWindows) {
try {
chmodSync(localPath, 0o755);
console.log(`✓ ${binaryName} already exists (permissions fixed)`);
} catch (e) {
console.log(`✓ ${binaryName} already exists`);
}
} else {
console.log(`✓ ${binaryName} already exists`);
}
continue;
}
// On Windows we avoid placing the executable inside node_modules to prevent
// EBUSY/EPERM during global upgrades when the binary is in use.
// We treat the user cache path as the canonical home of the native binary.
// For macOS/Linux we keep previous behavior and also place a copy in binDir
// for convenience.
// Fast path: if a valid cached binary exists for this version+triple, reuse it.
try {
if (existsSync(cachePath)) {
const valid = validateDownloadedBinary(cachePath);
if (valid.ok) {
copyFileSync(cachePath, localPath);
if (!isWindows) chmodSync(localPath, 0o755);
console.log(`✓ Installed ${binaryName} from user cache`);
if (!isWindows) {
// Mirror into local bin for Unix-like platforms
copyFileSync(cachePath, localPath);
try { chmodSync(localPath, 0o755); } catch {}
}
console.log(`✓ ${binaryName} ready from user cache`);
continue; // next binary
}
}
@@ -288,15 +281,14 @@ async function main() {
if (!existsSync(src)) {
throw new Error(`platform package missing binary: ${platformPkg.name}`);
}
copyFileSync(src, localPath);
if (!isWindows) chmodSync(localPath, 0o755);
console.log(`✓ Installed ${binaryName} from ${platformPkg.name}`);
// Populate cache for future npx runs
try {
if (!existsSync(cachePath)) {
copyFileSync(localPath, cachePath);
}
} catch {}
// Populate cache first (canonical location)
copyFileSync(src, cachePath);
if (!isWindows) {
// Mirror into local bin for Unix-like platforms only
copyFileSync(cachePath, localPath);
try { chmodSync(localPath, 0o755); } catch {}
}
console.log(`✓ Installed ${binaryName} from ${platformPkg.name} (cached)`);
continue; // next binary
} catch (e) {
console.warn(`⚠ Failed platform package install (${e.message}), falling back to GitHub download`);
@@ -357,9 +349,22 @@ async function main() {
}
// Validate header to avoid corrupt binaries causing spawn EFTYPE/ENOEXEC
const valid = validateDownloadedBinary(localPath);
// On Windows, archive extraction writes to binDir; move the result to cache
// and remove the copy from node_modules to avoid future locks. On Unix,
// we keep a copy in binDir and also ensure cache is populated.
if (isWindows) {
try {
// Ensure the extracted file is at localPath; then move it to cachePath
copyFileSync(localPath, cachePath);
try { unlinkSync(localPath); } catch {}
} catch (e) {
throw new Error(`failed to move binary to cache: ${e.message}`);
}
}
const valid = validateDownloadedBinary(isWindows ? cachePath : localPath);
if (!valid.ok) {
try { unlinkSync(localPath); } catch {}
try { isWindows ? unlinkSync(cachePath) : unlinkSync(localPath); } catch {}
throw new Error(`invalid binary (${valid.reason})`);
}
@@ -368,11 +373,11 @@ async function main() {
chmodSync(localPath, 0o755);
}
console.log(`✓ Installed ${binaryName}`);
// Save into persistent cache for future fast installs
try {
copyFileSync(localPath, cachePath);
} catch {}
console.log(`✓ Installed ${binaryName}${isWindows ? ' (cached)' : ''}`);
// Ensure persistent cache holds the binary (already true for Windows path)
if (!isWindows) {
try { copyFileSync(localPath, cachePath); } catch {}
}
} catch (error) {
console.error(`✗ Failed to install ${binaryName}: ${error.message}`);
console.error(` Downloaded from: ${downloadUrl}`);
@@ -384,13 +389,12 @@ async function main() {
const mainBinary = `code-${targetTriple}${binaryExt}`;
const mainBinaryPath = join(binDir, mainBinary);
if (existsSync(mainBinaryPath)) {
if (existsSync(mainBinaryPath) || existsSync(getCachedBinaryPath(version, targetTriple, platform() === 'win32'))) {
try {
const stats = statSync(mainBinaryPath);
if (!stats.size) {
throw new Error('binary is empty (download likely failed)');
}
const valid = validateDownloadedBinary(mainBinaryPath);
const probePath = existsSync(mainBinaryPath) ? mainBinaryPath : getCachedBinaryPath(version, targetTriple, platform() === 'win32');
const stats = statSync(probePath);
if (!stats.size) throw new Error('binary is empty (download likely failed)');
const valid = validateDownloadedBinary(probePath);
if (!valid.ok) {
console.warn(`⚠ Main code binary appears invalid: ${valid.reason}`);
console.warn(' Try reinstalling or check your network/proxy settings.');
+29 -3
View File
@@ -101,6 +101,13 @@ pub(crate) struct App<'a> {
/// profile defaults until all cells are explicitly painted.
clear_on_first_frame: bool,
/// Track last known terminal size. If it changes (true resize or a
/// tab switch that altered the viewport), perform a full clear on the next
/// draw to avoid ghost cells from the previous size. This is cheap and
/// happens rarely, but fixes Windows/macOS terminals that don't fully
/// repaint after focus/size changes until a manual resize occurs.
last_frame_size: Option<ratatui::prelude::Size>,
// Double‑Esc timing for backtrack/edit‑previous
last_esc_time: Option<Instant>,
@@ -284,6 +291,7 @@ impl App<'_> {
commit_anim_running: Arc::new(AtomicBool::new(false)),
terminal_info,
clear_on_first_frame: true,
last_frame_size: None,
last_esc_time: None,
timing_enabled: enable_perf,
timing: TimingStats::default(),
@@ -338,6 +346,10 @@ impl App<'_> {
// Insert an event to trigger the first render.
let app_event_tx = self.app_event_tx.clone();
app_event_tx.send(AppEvent::RequestRedraw);
// Some Windows/macOS terminals report an initial size that stabilizes
// shortly after entering the alt screen. Schedule one follow‑up frame
// to catch any late size change without polling.
app_event_tx.send(AppEvent::ScheduleFrameIn(Duration::from_millis(120)));
'main: loop {
let event = match self.next_event_priority() { Some(e) => e, None => break 'main };
@@ -1275,14 +1287,28 @@ impl App<'_> {
}
fn draw_next_frame(&mut self, terminal: &mut tui::Tui) -> Result<()> {
// Hard clear on the very first frame (and while onboarding) to ensure a
// clean background across terminals that don't respect our color attrs
// during EnterAlternateScreen.
if self.clear_on_first_frame || matches!(self.app_state, AppState::Onboarding { .. }) {
terminal.clear()?;
self.clear_on_first_frame = false;
}
// Terminal resize handling - simplified version since private fields aren't accessible
// The terminal will handle resize events internally
let _screen_size = terminal.size()?;
// If the terminal area changed (actual resize or tab switch that altered
// viewport), force a full clear once to prevent ghost artifacts. Some
// terminals on Windows/macOS do not reliably deliver Resize events on
// focus switches; querying the size each frame is cheap and lets us
// detect the change without extra event wiring.
let screen_size = terminal.size()?;
if self
.last_frame_size
.map(|prev| prev != screen_size)
.unwrap_or(false)
{
terminal.clear()?;
}
self.last_frame_size = Some(screen_size);
terminal.draw(|frame| {
match &mut self.app_state {