From 07441cc1e3f528cf76b52d049db23c7269649827 Mon Sep 17 00:00:00 2001 From: jinye Date: Tue, 5 May 2026 19:25:00 +0800 Subject: [PATCH] feat(sdk-python): add network timeouts to release version helper (#3833) --- .../sdk-python/scripts/get-release-version.js | 51 ++++++++++++++++- .../get-release-version-python-sdk.test.js | 57 +++++++++++++++++++ 2 files changed, 105 insertions(+), 3 deletions(-) diff --git a/packages/sdk-python/scripts/get-release-version.js b/packages/sdk-python/scripts/get-release-version.js index 28c32d5ec..b22bb2d39 100644 --- a/packages/sdk-python/scripts/get-release-version.js +++ b/packages/sdk-python/scripts/get-release-version.js @@ -21,6 +21,8 @@ const __dirname = dirname(__filename); const PACKAGE_NAME = 'qwen-code-sdk'; const TAG_PREFIX = 'sdk-python-'; +const NETWORK_COMMAND_TIMEOUT_MS = 30_000; +const LOCAL_COMMAND_TIMEOUT_MS = 10_000; function readPyprojectVersion() { const pyprojectPath = join(__dirname, '..', 'pyproject.toml'); @@ -117,7 +119,7 @@ function toBaseVersion(version) { async function getAllVersionsFromPyPI() { const response = await fetch(`https://pypi.org/pypi/${PACKAGE_NAME}/json`, { headers: { Accept: 'application/json' }, - signal: AbortSignal.timeout(30_000), + signal: AbortSignal.timeout(NETWORK_COMMAND_TIMEOUT_MS), }); if (response.status === 404) { @@ -241,7 +243,33 @@ function getUtcTimestamp() { } function getGitShortHash() { - return execSync('git rev-parse --short HEAD').toString().trim(); + try { + return execSync('git rev-parse --short HEAD', { + timeout: LOCAL_COMMAND_TIMEOUT_MS, + }) + .toString() + .trim(); + } catch (error) { + if (isTimeoutError(error)) { + throw new Error( + `git rev-parse timed out after ${LOCAL_COMMAND_TIMEOUT_MS / 1000}s — local git may be unresponsive`, + ); + } + throw error; + } +} + +function isTimeoutError(error) { + // Node.js execSync timeout: `code` is 'ETIMEDOUT' on POSIX; on some + // versions/platforms `killed` is true with signal 'SIGTERM' or null. + // Match the pattern used in packages/core/src/utils/pdf.ts. + return ( + error.code === 'ETIMEDOUT' || + (error.killed === true && + (error.signal === 'SIGTERM' || + error.signal === undefined || + error.signal === null)) + ); } async function getReleaseState({ packageVersion, releaseTag }, allVersions) { @@ -252,17 +280,27 @@ async function getReleaseState({ packageVersion, releaseTag }, allVersions) { }; const fullTag = `${TAG_PREFIX}${releaseTag}`; try { - const tagOutput = execSync(`git tag -l '${fullTag}'`).toString().trim(); + const tagOutput = execSync(`git tag -l '${fullTag}'`, { + timeout: LOCAL_COMMAND_TIMEOUT_MS, + }) + .toString() + .trim(); if (tagOutput === fullTag) { state.gitTagExists = true; } } catch (error) { + if (isTimeoutError(error)) { + throw new Error( + `git tag -l timed out after ${LOCAL_COMMAND_TIMEOUT_MS / 1000}s — local git may be unresponsive`, + ); + } throw new Error(`Failed to check git tags for conflicts: ${error.message}`); } try { const output = execSync( `gh release view "${fullTag}" --json tagName --jq .tagName`, + { timeout: NETWORK_COMMAND_TIMEOUT_MS }, ) .toString() .trim(); @@ -270,6 +308,13 @@ async function getReleaseState({ packageVersion, releaseTag }, allVersions) { state.githubReleaseExists = true; } } catch (error) { + // Timeout check must precede isExpectedMissingGitHubRelease — a timed-out + // process may emit partial stderr matching "release not found". + if (isTimeoutError(error)) { + throw new Error( + `gh release view timed out after ${NETWORK_COMMAND_TIMEOUT_MS / 1000}s checking "${fullTag}" — GitHub API may be unavailable`, + ); + } if (!isExpectedMissingGitHubRelease(error)) { throw new Error( `Failed to check GitHub releases for conflicts: ${error.message}`, diff --git a/scripts/tests/get-release-version-python-sdk.test.js b/scripts/tests/get-release-version-python-sdk.test.js index c8b170adf..9c7976bd5 100644 --- a/scripts/tests/get-release-version-python-sdk.test.js +++ b/scripts/tests/get-release-version-python-sdk.test.js @@ -50,6 +50,16 @@ function makeExecError(message, { stderr = '', stdout = '', status } = {}) { return error; } +function makeTimeoutError(command) { + const error = new Error(`Command failed: ${command}\nSIGTERM`); + // Real Node.js execSync timeout shape (verified on Node 20+): + // killed=undefined, signal='SIGTERM', code='ETIMEDOUT' + error.code = 'ETIMEDOUT'; + error.signal = 'SIGTERM'; + error.status = null; + return error; +} + function makeExecSyncMock({ tags = {}, tagError = null, @@ -927,4 +937,51 @@ describe('python sdk get-release-version', () => { resumeExistingRelease: true, }); }); + + it('throws a timeout error when gh release view times out', async () => { + execSyncMock.mockImplementation( + makeExecSyncMock({ + releases: { + 'sdk-python-v0.1.0-preview.0': makeTimeoutError( + 'gh release view "sdk-python-v0.1.0-preview.0"', + ), + }, + }), + ); + + const getVersion = await loadGetVersion(); + + await expect(getVersion({ type: 'preview' })).rejects.toThrow( + 'gh release view timed out after 30s checking "sdk-python-v0.1.0-preview.0" — GitHub API may be unavailable', + ); + }); + + it('throws a timeout error when git tag -l times out', async () => { + execSyncMock.mockImplementation( + makeExecSyncMock({ + tagError: makeTimeoutError('git tag -l'), + }), + ); + + const getVersion = await loadGetVersion(); + + await expect(getVersion({ type: 'preview' })).rejects.toThrow( + 'git tag -l timed out after 10s — local git may be unresponsive', + ); + }); + + it('throws a timeout error when git rev-parse times out', async () => { + execSyncMock.mockImplementation((command) => { + if (command === 'git rev-parse --short HEAD') { + throw makeTimeoutError('git rev-parse --short HEAD'); + } + return makeExecSyncMock()(command); + }); + + const getVersion = await loadGetVersion(); + + await expect(getVersion({ type: 'nightly' })).rejects.toThrow( + 'git rev-parse timed out after 10s — local git may be unresponsive', + ); + }); });