From 730f2243f67f2b3d6a89cfe4cfbce1282a4df01d Mon Sep 17 00:00:00 2001 From: kite Date: Mon, 3 Aug 2026 21:30:41 +0800 Subject: [PATCH 1/2] fix(npm): prevent binary unavailability during auto-upgrade (#703) Add a binary cache fallback mechanism to ensure ocr remains available while the background auto-upgrade is running npm i -g. Before the upgrade, the current binary is copied to ~/.opencodereview/bin/ as a safety net. resolveNativeBinary() now checks this cache path as a last resort before returning null. This fixes: - Unix: the gap window where binary is deleted before new one installs - Windows: broken installs when npm i -g fails due to file locks --- scripts/platform.js | 12 ++++++++++-- scripts/update.js | 20 ++++++++++++++++++++ 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/scripts/platform.js b/scripts/platform.js index 3972669c..ef8eaa62 100644 --- a/scripts/platform.js +++ b/scripts/platform.js @@ -2,6 +2,7 @@ const path = require("path"); const fs = require("fs"); +const os = require("os"); const IS_WINDOWS = process.platform === "win32"; const BINARY_FILENAME = IS_WINDOWS ? "opencodereview.exe" : "opencodereview"; @@ -40,7 +41,7 @@ function resolveNativeBinary() { const pkgDir = path.dirname(require.resolve(`${pkgName}/package.json`)); const binPath = path.join(pkgDir, "bin", BINARY_FILENAME); if (fs.existsSync(binPath)) { - return { path: binPath, fromPlatformPkg: true }; + return { path: binPath, fromPlatformPkg: true, fromCache: false }; } } catch (err) { if (err.code !== "MODULE_NOT_FOUND") { @@ -51,7 +52,14 @@ function resolveNativeBinary() { const legacyPath = path.join(__dirname, "..", "bin", BINARY_FILENAME); if (fs.existsSync(legacyPath)) { - return { path: legacyPath, fromPlatformPkg: false }; + return { path: legacyPath, fromPlatformPkg: false, fromCache: false }; + } + + const cachePath = path.join( + os.homedir(), ".opencodereview", "bin", BINARY_FILENAME + ); + if (fs.existsSync(cachePath)) { + return { path: cachePath, fromPlatformPkg: false, fromCache: true }; } return null; diff --git a/scripts/update.js b/scripts/update.js index ba6fc249..de912870 100644 --- a/scripts/update.js +++ b/scripts/update.js @@ -15,6 +15,7 @@ const tsFile = path.join(stateDir, "last-update-check"); const lockFile = path.join(stateDir, "update.lock"); const hintFile = path.join(stateDir, "update-available"); +const CACHE_BIN_DIR = path.join(stateDir, "bin"); const DEFAULT_REGISTRY = "https://registry.npmjs.org"; function touchTimestamp() { @@ -136,6 +137,19 @@ function removeHint() { } catch (_) {} } +function cacheBinary(srcPath) { + try { + fs.mkdirSync(CACHE_BIN_DIR, { recursive: true }); + const dest = path.join(CACHE_BIN_DIR, path.basename(srcPath)); + const tmp = dest + ".tmp"; + fs.copyFileSync(srcPath, tmp); + fs.renameSync(tmp, dest); + if (process.platform !== "win32") { + fs.chmodSync(dest, 0o755); + } + } catch (_) {} +} + async function main() { touchTimestamp(); @@ -159,6 +173,8 @@ async function main() { return; } + cacheBinary(resolved.path); + const pkgName = pkg.name; const IS_WINDOWS = process.platform === "win32"; const result = spawnSync("npm", ["i", "-g", `${pkgName}@${latestVersion}`], { @@ -169,6 +185,10 @@ async function main() { if (result.status === 0) { removeHint(); + const newResolved = resolveNativeBinary(); + if (newResolved && !newResolved.fromCache) { + cacheBinary(newResolved.path); + } } else { writeHint(latestVersion, pkgName); } From c2312f97440e8b7b9b64d9e2004d800b4eaa07ae Mon Sep 17 00:00:00 2001 From: kite Date: Tue, 4 Aug 2026 16:58:21 +0800 Subject: [PATCH 2/2] fix(npm): improve binary staging for zero-downtime auto-upgrade Replace the simple binary cache with a proper staged binary mechanism: - Add version.json metadata tracking (version, timestamp, platform) - Add platform validation to prevent cross-platform misuse - Add file integrity checks (exists, non-empty) in resolveStagedBinary() - Notify users via dim stderr message when running from staged binary - Refresh staged binary after successful upgrade for next-time protection - Reuse STATE_DIR from platform.js to eliminate path duplication - Always write version.json to avoid orphan binaries on disk Resolution priority: platform package > staged binary > legacy path. --- bin/ocr.js | 7 ++++++ scripts/platform.js | 59 ++++++++++++++++++++++++++++++++++++++------- scripts/update.js | 56 +++++++++++++++++++++++++++--------------- 3 files changed, 94 insertions(+), 28 deletions(-) diff --git a/bin/ocr.js b/bin/ocr.js index a1e66437..33902c42 100755 --- a/bin/ocr.js +++ b/bin/ocr.js @@ -17,6 +17,13 @@ if (!resolved) { } const binaryPath = resolved.path; +if (resolved.fromStaged) { + const ver = resolved.stagedVersion ? ` (v${resolved.stagedVersion})` : ""; + process.stderr.write( + `\x1b[2m[ocr] Using staged binary${ver}; run npm i -g @alibaba-group/open-code-review to reinstall.\x1b[0m\n` + ); +} + const hintFile = path.join(os.homedir(), ".opencodereview", "update-available"); try { const hint = JSON.parse(fs.readFileSync(hintFile, "utf8")); diff --git a/scripts/platform.js b/scripts/platform.js index ef8eaa62..406d7261 100644 --- a/scripts/platform.js +++ b/scripts/platform.js @@ -7,6 +7,10 @@ const os = require("os"); const IS_WINDOWS = process.platform === "win32"; const BINARY_FILENAME = IS_WINDOWS ? "opencodereview.exe" : "opencodereview"; +const STATE_DIR = path.join(os.homedir(), ".opencodereview"); +const STAGED_BIN_DIR = path.join(STATE_DIR, "staged"); +const VERSION_JSON_PATH = path.join(STAGED_BIN_DIR, "version.json"); + const PLATFORM_PKG = { "darwin-arm64": "@alibaba-group/ocr-darwin-arm64", "darwin-x64": "@alibaba-group/ocr-darwin-x64", @@ -34,6 +38,41 @@ function getPlatformPackageName() { return PLATFORM_PKG[key] || null; } +function resolveStagedBinary() { + try { + const raw = fs.readFileSync(VERSION_JSON_PATH, "utf8"); + const meta = JSON.parse(raw); + if (!meta.version || !meta.stagedAt || !meta.platform) { + return null; + } + + const currentPlatform = `${process.platform}-${process.arch}`; + if (meta.platform !== currentPlatform) { + return null; + } + + const binPath = path.join(STAGED_BIN_DIR, BINARY_FILENAME); + let stat; + try { + stat = fs.statSync(binPath); + } catch (_) { + return null; + } + if (!stat.isFile() || stat.size === 0) { + return null; + } + + return { + path: binPath, + fromPlatformPkg: false, + fromStaged: true, + stagedVersion: meta.version, + }; + } catch (_) { + return null; + } +} + function resolveNativeBinary() { const pkgName = getPlatformPackageName(); if (pkgName) { @@ -41,7 +80,7 @@ function resolveNativeBinary() { const pkgDir = path.dirname(require.resolve(`${pkgName}/package.json`)); const binPath = path.join(pkgDir, "bin", BINARY_FILENAME); if (fs.existsSync(binPath)) { - return { path: binPath, fromPlatformPkg: true, fromCache: false }; + return { path: binPath, fromPlatformPkg: true, fromStaged: false }; } } catch (err) { if (err.code !== "MODULE_NOT_FOUND") { @@ -50,16 +89,14 @@ function resolveNativeBinary() { } } - const legacyPath = path.join(__dirname, "..", "bin", BINARY_FILENAME); - if (fs.existsSync(legacyPath)) { - return { path: legacyPath, fromPlatformPkg: false, fromCache: false }; + const staged = resolveStagedBinary(); + if (staged) { + return staged; } - const cachePath = path.join( - os.homedir(), ".opencodereview", "bin", BINARY_FILENAME - ); - if (fs.existsSync(cachePath)) { - return { path: cachePath, fromPlatformPkg: false, fromCache: true }; + const legacyPath = path.join(__dirname, "..", "bin", BINARY_FILENAME); + if (fs.existsSync(legacyPath)) { + return { path: legacyPath, fromPlatformPkg: false, fromStaged: false }; } return null; @@ -69,6 +106,10 @@ module.exports = { IS_WINDOWS, BINARY_FILENAME, PLATFORM_PKG, + STATE_DIR, + STAGED_BIN_DIR, + VERSION_JSON_PATH, getPlatformPackageName, + resolveStagedBinary, resolveNativeBinary, }; diff --git a/scripts/update.js b/scripts/update.js index de912870..a3e2f5a5 100644 --- a/scripts/update.js +++ b/scripts/update.js @@ -3,23 +3,27 @@ const fs = require("fs"); const path = require("path"); -const os = require("os"); const https = require("https"); const { spawnSync } = require("child_process"); -const { resolveNativeBinary } = require("./platform"); +const { + resolveNativeBinary, + IS_WINDOWS, + BINARY_FILENAME, + STATE_DIR, + STAGED_BIN_DIR, + VERSION_JSON_PATH, +} = require("./platform"); const { loadPackageJson } = require("./install.js"); -const stateDir = path.join(os.homedir(), ".opencodereview"); -const tsFile = path.join(stateDir, "last-update-check"); -const lockFile = path.join(stateDir, "update.lock"); -const hintFile = path.join(stateDir, "update-available"); +const tsFile = path.join(STATE_DIR, "last-update-check"); +const lockFile = path.join(STATE_DIR, "update.lock"); +const hintFile = path.join(STATE_DIR, "update-available"); -const CACHE_BIN_DIR = path.join(stateDir, "bin"); const DEFAULT_REGISTRY = "https://registry.npmjs.org"; function touchTimestamp() { - fs.mkdirSync(stateDir, { recursive: true }); + fs.mkdirSync(STATE_DIR, { recursive: true }); const now = new Date(); try { fs.utimesSync(tsFile, now, now); @@ -29,7 +33,7 @@ function touchTimestamp() { } function acquireLock() { - fs.mkdirSync(stateDir, { recursive: true }); + fs.mkdirSync(STATE_DIR, { recursive: true }); try { fs.writeFileSync(lockFile, String(process.pid), { flag: "wx" }); return true; @@ -137,16 +141,27 @@ function removeHint() { } catch (_) {} } -function cacheBinary(srcPath) { +function writeVersionJson(version) { + const meta = { + version, + stagedAt: new Date().toISOString(), + platform: `${process.platform}-${process.arch}`, + }; + fs.writeFileSync(VERSION_JSON_PATH, JSON.stringify(meta, null, 2)); +} + +function stageBinary(srcPath) { try { - fs.mkdirSync(CACHE_BIN_DIR, { recursive: true }); - const dest = path.join(CACHE_BIN_DIR, path.basename(srcPath)); + fs.mkdirSync(STAGED_BIN_DIR, { recursive: true }); + const dest = path.join(STAGED_BIN_DIR, BINARY_FILENAME); const tmp = dest + ".tmp"; fs.copyFileSync(srcPath, tmp); - fs.renameSync(tmp, dest); - if (process.platform !== "win32") { - fs.chmodSync(dest, 0o755); + if (!IS_WINDOWS) { + fs.chmodSync(tmp, 0o755); } + fs.renameSync(tmp, dest); + + writeVersionJson(getInstalledVersion(dest) || "unknown"); } catch (_) {} } @@ -173,10 +188,12 @@ async function main() { return; } - cacheBinary(resolved.path); + // Stage current binary before npm i -g to cover the gap window + if (!resolved.fromStaged) { + stageBinary(resolved.path); + } const pkgName = pkg.name; - const IS_WINDOWS = process.platform === "win32"; const result = spawnSync("npm", ["i", "-g", `${pkgName}@${latestVersion}`], { encoding: "utf8", timeout: 120000, @@ -185,9 +202,10 @@ async function main() { if (result.status === 0) { removeHint(); + // Refresh staged binary with the newly installed version const newResolved = resolveNativeBinary(); - if (newResolved && !newResolved.fromCache) { - cacheBinary(newResolved.path); + if (newResolved && !newResolved.fromStaged) { + stageBinary(newResolved.path); } } else { writeHint(latestVersion, pkgName);