feat(cli): auto-download protoc-gen-openapi binary when not on PATH#13667
feat(cli): auto-download protoc-gen-openapi binary when not on PATH#13667Swimburger merged 15 commits intomainfrom
Conversation
Co-Authored-By: Niels Swimberghe <3382717+Swimburger@users.noreply.github.com>
🤖 Devin AI EngineerI'll be helping with this pull request! Here's what you should know: ✅ I will automatically:
Note: I can only respond to comments from users who have write access to this repository. ⚙️ Control Options:
|
🌱 Seed Test SelectorSelect languages to run seed tests for:
How to use: Click the ⋯ menu above → "Edit" → check the boxes you want → click "Update comment". Tests will run automatically and snapshots will be committed to this PR. |
| // If we downloaded protoc-gen-openapi, prepend its directory to PATH so buf can find it | ||
| const envOverride = | ||
| this.protocGenOpenAPIBinDir != null | ||
| ? { PATH: `${this.protocGenOpenAPIBinDir}:${process.env.PATH ?? ""}` } |
There was a problem hiding this comment.
Uses hardcoded : as PATH separator, which breaks on Windows. Windows requires ; as the PATH separator.
On Windows systems, this will result in a malformed PATH that won't find the downloaded binary, causing the buf command to fail even though the binary was successfully downloaded.
// Fix: Use platform-specific PATH separator
const pathSeparator = process.platform === 'win32' ? ';' : ':';
const envOverride =
this.protocGenOpenAPIBinDir != null
? { PATH: `${this.protocGenOpenAPIBinDir}${pathSeparator}${process.env.PATH ?? ""}` }
: undefined;| ? { PATH: `${this.protocGenOpenAPIBinDir}:${process.env.PATH ?? ""}` } | |
| ? { PATH: `${this.protocGenOpenAPIBinDir}${process.platform === 'win32' ? ';' : ':'}${process.env.PATH ?? ""}` } |
Spotted by Graphite
Is this helpful? React 👍 or 👎 to let us know.
… catch, stale binary) Co-Authored-By: Niels Swimberghe <3382717+Swimburger@users.noreply.github.com>
…enapi Co-Authored-By: Niels Swimberghe <3382717+Swimburger@users.noreply.github.com>
| const tmpPath = AbsoluteFilePath.of(`${cachedBinaryPath}.tmp`); | ||
| const response = await fetch(downloadUrl, { redirect: "follow" }); | ||
| if (!response.ok) { | ||
| logger.debug(`Failed to download protoc-gen-openapi: ${response.status} ${response.statusText}`); | ||
| return undefined; | ||
| } | ||
|
|
||
| const arrayBuffer = await response.arrayBuffer(); | ||
| writeFileSync(tmpPath, new Uint8Array(arrayBuffer)); | ||
|
|
||
| await chmod(tmpPath, 0o755); | ||
| await rename(tmpPath, cachedBinaryPath); |
There was a problem hiding this comment.
Critical Race Condition: When multiple CLI processes run concurrently without the cached binary, they all attempt to download to the same temporary file path ${cachedBinaryPath}.tmp. This causes:
- File corruption from concurrent writes
- Potential failures when one process tries to rename while another is writing
- Incomplete or corrupted binary being moved to the final cached location
Fix: Use a unique temporary filename for each process:
const tmpPath = AbsoluteFilePath.of(`${cachedBinaryPath}.tmp.${process.pid}.${Date.now()}`);Or use a proper temporary file library that guarantees unique names. This ensures each concurrent download uses its own temporary file.
| const tmpPath = AbsoluteFilePath.of(`${cachedBinaryPath}.tmp`); | |
| const response = await fetch(downloadUrl, { redirect: "follow" }); | |
| if (!response.ok) { | |
| logger.debug(`Failed to download protoc-gen-openapi: ${response.status} ${response.statusText}`); | |
| return undefined; | |
| } | |
| const arrayBuffer = await response.arrayBuffer(); | |
| writeFileSync(tmpPath, new Uint8Array(arrayBuffer)); | |
| await chmod(tmpPath, 0o755); | |
| await rename(tmpPath, cachedBinaryPath); | |
| const tmpPath = AbsoluteFilePath.of(`${cachedBinaryPath}.tmp.${process.pid}.${Date.now()}`); | |
| const response = await fetch(downloadUrl, { redirect: "follow" }); | |
| if (!response.ok) { | |
| logger.debug(`Failed to download protoc-gen-openapi: ${response.status} ${response.statusText}`); | |
| return undefined; | |
| } | |
| const arrayBuffer = await response.arrayBuffer(); | |
| writeFileSync(tmpPath, new Uint8Array(arrayBuffer)); | |
| await chmod(tmpPath, 0o755); | |
| await rename(tmpPath, cachedBinaryPath); | |
Spotted by Graphite
Is this helpful? React 👍 or 👎 to let us know.
…en-openapi caching Co-Authored-By: Niels Swimberghe <3382717+Swimburger@users.noreply.github.com>
| // Timeout — force-break the presumed-stale lock and retry once | ||
| logger.debug(`Lock timed out after ${LOCK_TIMEOUT_MS}ms, breaking stale lock`); | ||
| try { | ||
| await rm(lockPath, { recursive: true }); | ||
| } catch { | ||
| // Ignore — another process may have already removed it | ||
| } | ||
| await mkdir(lockPath, { recursive: false }); |
There was a problem hiding this comment.
Race condition in lock breaking logic causes crashes. After the timeout, the code removes the stale lock and immediately tries to acquire it. However, between rm() and mkdir(), another process could acquire the lock first, causing mkdir() to throw an EEXIST error that crashes this process.
// Fix: Retry the normal acquisition loop instead of assuming we get the lock
try {
await rm(lockPath, { recursive: true });
} catch {
// Ignore
}
// Retry acquisition instead of assuming we get the lock
return acquireLock(logger); // Or loop back to the start| // Timeout — force-break the presumed-stale lock and retry once | |
| logger.debug(`Lock timed out after ${LOCK_TIMEOUT_MS}ms, breaking stale lock`); | |
| try { | |
| await rm(lockPath, { recursive: true }); | |
| } catch { | |
| // Ignore — another process may have already removed it | |
| } | |
| await mkdir(lockPath, { recursive: false }); | |
| // Timeout — force-break the presumed-stale lock and retry once | |
| logger.debug(`Lock timed out after ${LOCK_TIMEOUT_MS}ms, breaking stale lock`); | |
| try { | |
| await rm(lockPath, { recursive: true }); | |
| } catch { | |
| // Ignore — another process may have already removed it | |
| } | |
| // Retry acquisition instead of assuming we get the lock | |
| return acquireLock(logger); | |
Spotted by Graphite
Is this helpful? React 👍 or 👎 to let us know.
…-break Co-Authored-By: Niels Swimberghe <3382717+Swimburger@users.noreply.github.com>
Co-Authored-By: Niels Swimberghe <3382717+Swimburger@users.noreply.github.com>
…turn undefined on failure Co-Authored-By: Niels Swimberghe <3382717+Swimburger@users.noreply.github.com>
Co-Authored-By: Niels Swimberghe <3382717+Swimburger@users.noreply.github.com>
| } catch { | ||
| return false; | ||
| } |
There was a problem hiding this comment.
🟡 Catch block in fileExists silently swallows errors without logging (REVIEW.md violation)
The catch block at packages/cli/workspace/lazy-fern-workspace/src/protobuf/ProtocGenOpenAPIDownloader.ts:96 returns false without logging the error. REVIEW.md mandates: "No empty catch blocks -- at minimum log the error." While the block isn't literally empty (it has return false), it does not log the caught error at all. Other catch blocks in this same file (e.g. lines 125-126, 130-132, 143-144, 224-227) properly capture and log the error, making this an inconsistency.
Was this helpful? React with 👍 or 👎 to provide feedback.
There was a problem hiding this comment.
This is intentional — fileExists is a thin wrapper around fs.access where the expected failure case is ENOENT (file not found). Logging every "file not found" check at debug level would be noisy since this function is called frequently in the hot path (checking versioned binary, canonical binary, version marker). The other catch blocks in this file handle truly unexpected errors (download failures, lock failures, cleanup failures), which is why they log.
That said, if a human reviewer feels strongly about consistency here, happy to add a debug log.
packages/cli/workspace/lazy-fern-workspace/src/protobuf/ProtocGenOpenAPIDownloader.ts
Outdated
Show resolved
Hide resolved
…bals to afterEach Co-Authored-By: Niels Swimberghe <3382717+Swimburger@users.noreply.github.com>
Self-ReviewIssues found and fixed (commit
|
Co-Authored-By: Niels Swimberghe <3382717+Swimburger@users.noreply.github.com>
Co-Authored-By: Niels Swimberghe <3382717+Swimburger@users.noreply.github.com>
Co-Authored-By: Niels Swimberghe <3382717+Swimburger@users.noreply.github.com>
Co-Authored-By: Niels Swimberghe <3382717+Swimburger@users.noreply.github.com>
…tHub Releases Co-Authored-By: Niels Swimberghe <3382717+Swimburger@users.noreply.github.com>
…enapi Co-Authored-By: Niels Swimberghe <3382717+Swimburger@users.noreply.github.com>
…ern-api#13667) Co-authored-by: Niels Swimberghe <3382717+Swimburger@users.noreply.github.com> Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Description
Refs fern-api/protoc-gen-openapi#22
When generating SDKs from proto files, the CLI currently requires
protoc-gen-openapito be pre-installed on PATH. In CI, this means installing Go and compiling from source viago installon every run (~31s). This PR adds automatic download of a pre-built binary from GitHub Releases, caching it at~/.fern/bin/.The companion PR (fern-api/protoc-gen-openapi#22) is merged and v0.1.13 binaries are published.
Changes Made
ProtocGenOpenAPIDownloader.ts: Downloads theprotoc-gen-openapiv0.1.13 binary for the current platform/arch from GitHub Releases. Supports linux/darwin/windows on amd64/arm64.protoc-gen-openapi-v0.1.13). A.versionmarker file tracks which version the canonicalprotoc-gen-openapibinary corresponds to. WhenPROTOC_GEN_OPENAPI_VERSIONis bumped in code, the canonical binary is atomically replaced on next invocation.mkdir-based, atomic on all platforms) is held during download and file operations. All file replacements use write-to-temp +rename()for atomicity. Lock force-break handles concurrent EEXIST with retry.resolveProtocGenOpenAPIwraps all logic (including lock acquisition) so unexpected errors returnundefinedinstead of crashing the caller.ProtobufOpenAPIGenerator.ts: Whenprotoc-gen-openapiis not on PATH, attempts auto-download before failing. Prepends the cache directory tobuf's PATH usingpath.delimiter(cross-platform).ProtocGenOpenAPIDownloader.test.ts: 12 tests total:versions.yml: Added v4.35.0 changelog entry.Review Checklist
— Fixed: top-level try-catch inacquireLockcan throw throughresolveProtocGenOpenAPIresolveProtocGenOpenAPIensures lock errors returnundefinedgracefully.— Fixed: now uses asyncwriteFileSyncfor downloadwriteFilewithnew Uint8Array(arrayBuffer).v0.1.13— pinned inProtocGenOpenAPIDownloader.ts. Future protoc-gen-openapi releases require a manual CLI version bump. Is this acceptable or should it be configurable?protoc-gen-openapi-v0.1.12) remain on disk. Could accumulate over time (~11MB each).fileExistscatch block has no logging — intentional: it handles expectedENOENTerrors on a hot path; logging every file-not-found check would be noisy.Testing
pnpm run check)pnpm run fern:build)fern check --api csharp-grpc-proto --log-level debugwith local CLI build, confirmed download → cache → reuse flow worksLink to Devin session: https://app.devin.ai/sessions/9aa3a5abd77d4e069bcfade48423e986
Requested by: @Swimburger