mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-03-21 23:26:34 +01:00
🔌 fix: Isolate Code-Server HTTP Agents to Prevent Socket Pool Contamination (#12311)
* 🔧 fix: Isolate HTTP agents for code-server axios requests Prevents socket hang up after 5s on Node 19+ when code executor has file attachments. follow-redirects (axios dep) leaks `socket.destroy` as a timeout listener on TCP sockets; with Node 19+ defaulting to keepAlive: true, tainted sockets re-enter the global pool and destroy active node-fetch requests in CodeExecutor after the idle timeout. Uses dedicated http/https agents with keepAlive: false for all axios calls targeting CODE_BASEURL in crud.js and process.js. Closes #12298 * ♻️ refactor: Extract code-server HTTP agents to shared module - Move duplicated agent construction from crud.js and process.js into a shared agents.js module to eliminate DRY violation - Switch process.js from raw `require('axios')` to `createAxiosInstance()` for proxy configuration parity with crud.js - Fix import ordering in process.js (agent constants no longer split imports) - Add 120s timeout to uploadCodeEnvFile (was the only code-server call without a timeout) * ✅ test: Add regression tests for code-server socket isolation - Add crud.spec.js covering getCodeOutputDownloadStream and uploadCodeEnvFile (agent options, timeout, URL, error handling) - Add socket pool isolation tests to process.spec.js asserting keepAlive:false agents are forwarded to axios - Update process.spec.js mocks for createAxiosInstance() migration * ♻️ refactor: Move code-server agents to packages/api Relocate agents.js from api/server/services/Files/Code/ to packages/api/src/utils/code.ts per workspace conventions. Consumers now import codeServerHttpAgent/codeServerHttpsAgent from @librechat/api.
This commit is contained in:
parent
7e74165c3c
commit
39f5f83a8a
7 changed files with 273 additions and 45 deletions
|
|
@ -10,11 +10,23 @@ jest.mock('@librechat/agents', () => ({
|
|||
|
||||
const mockSanitizeFilename = jest.fn();
|
||||
|
||||
jest.mock('@librechat/api', () => ({
|
||||
logAxiosError: jest.fn(),
|
||||
getBasePath: jest.fn(() => ''),
|
||||
sanitizeFilename: mockSanitizeFilename,
|
||||
}));
|
||||
const mockAxios = jest.fn().mockResolvedValue({
|
||||
data: Buffer.from('file-content'),
|
||||
});
|
||||
mockAxios.post = jest.fn();
|
||||
|
||||
jest.mock('@librechat/api', () => {
|
||||
const http = require('http');
|
||||
const https = require('https');
|
||||
return {
|
||||
logAxiosError: jest.fn(),
|
||||
getBasePath: jest.fn(() => ''),
|
||||
sanitizeFilename: mockSanitizeFilename,
|
||||
createAxiosInstance: jest.fn(() => mockAxios),
|
||||
codeServerHttpAgent: new http.Agent({ keepAlive: false }),
|
||||
codeServerHttpsAgent: new https.Agent({ keepAlive: false }),
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('librechat-data-provider', () => ({
|
||||
...jest.requireActual('librechat-data-provider'),
|
||||
|
|
@ -53,12 +65,6 @@ jest.mock('~/server/utils', () => ({
|
|||
determineFileType: jest.fn().mockResolvedValue({ mime: 'text/csv' }),
|
||||
}));
|
||||
|
||||
jest.mock('axios', () =>
|
||||
jest.fn().mockResolvedValue({
|
||||
data: Buffer.from('file-content'),
|
||||
}),
|
||||
);
|
||||
|
||||
const { createFile } = require('~/models');
|
||||
const { processCodeOutput } = require('../process');
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,11 @@
|
|||
const FormData = require('form-data');
|
||||
const { getCodeBaseURL } = require('@librechat/agents');
|
||||
const { createAxiosInstance, logAxiosError } = require('@librechat/api');
|
||||
const {
|
||||
logAxiosError,
|
||||
createAxiosInstance,
|
||||
codeServerHttpAgent,
|
||||
codeServerHttpsAgent,
|
||||
} = require('@librechat/api');
|
||||
|
||||
const axios = createAxiosInstance();
|
||||
|
||||
|
|
@ -25,6 +30,8 @@ async function getCodeOutputDownloadStream(fileIdentifier, apiKey) {
|
|||
'User-Agent': 'LibreChat/1.0',
|
||||
'X-API-Key': apiKey,
|
||||
},
|
||||
httpAgent: codeServerHttpAgent,
|
||||
httpsAgent: codeServerHttpsAgent,
|
||||
timeout: 15000,
|
||||
};
|
||||
|
||||
|
|
@ -69,6 +76,9 @@ async function uploadCodeEnvFile({ req, stream, filename, apiKey, entity_id = ''
|
|||
'User-Id': req.user.id,
|
||||
'X-API-Key': apiKey,
|
||||
},
|
||||
httpAgent: codeServerHttpAgent,
|
||||
httpsAgent: codeServerHttpsAgent,
|
||||
timeout: 120000,
|
||||
maxContentLength: MAX_FILE_SIZE,
|
||||
maxBodyLength: MAX_FILE_SIZE,
|
||||
};
|
||||
|
|
|
|||
149
api/server/services/Files/Code/crud.spec.js
Normal file
149
api/server/services/Files/Code/crud.spec.js
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
const http = require('http');
|
||||
const https = require('https');
|
||||
const { Readable } = require('stream');
|
||||
|
||||
const mockAxios = jest.fn();
|
||||
mockAxios.post = jest.fn();
|
||||
|
||||
jest.mock('@librechat/agents', () => ({
|
||||
getCodeBaseURL: jest.fn(() => 'https://code-api.example.com'),
|
||||
}));
|
||||
|
||||
jest.mock('@librechat/api', () => {
|
||||
const http = require('http');
|
||||
const https = require('https');
|
||||
return {
|
||||
logAxiosError: jest.fn(({ message }) => message),
|
||||
createAxiosInstance: jest.fn(() => mockAxios),
|
||||
codeServerHttpAgent: new http.Agent({ keepAlive: false }),
|
||||
codeServerHttpsAgent: new https.Agent({ keepAlive: false }),
|
||||
};
|
||||
});
|
||||
|
||||
const { codeServerHttpAgent, codeServerHttpsAgent } = require('@librechat/api');
|
||||
const { getCodeOutputDownloadStream, uploadCodeEnvFile } = require('./crud');
|
||||
|
||||
describe('Code CRUD', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('getCodeOutputDownloadStream', () => {
|
||||
it('should pass dedicated keepAlive:false agents to axios', async () => {
|
||||
const mockResponse = { data: Readable.from(['chunk']) };
|
||||
mockAxios.mockResolvedValue(mockResponse);
|
||||
|
||||
await getCodeOutputDownloadStream('session-1/file-1', 'test-key');
|
||||
|
||||
const callConfig = mockAxios.mock.calls[0][0];
|
||||
expect(callConfig.httpAgent).toBe(codeServerHttpAgent);
|
||||
expect(callConfig.httpsAgent).toBe(codeServerHttpsAgent);
|
||||
expect(callConfig.httpAgent).toBeInstanceOf(http.Agent);
|
||||
expect(callConfig.httpsAgent).toBeInstanceOf(https.Agent);
|
||||
expect(callConfig.httpAgent.keepAlive).toBe(false);
|
||||
expect(callConfig.httpsAgent.keepAlive).toBe(false);
|
||||
});
|
||||
|
||||
it('should request stream response from the correct URL', async () => {
|
||||
mockAxios.mockResolvedValue({ data: Readable.from(['chunk']) });
|
||||
|
||||
await getCodeOutputDownloadStream('session-1/file-1', 'test-key');
|
||||
|
||||
const callConfig = mockAxios.mock.calls[0][0];
|
||||
expect(callConfig.url).toBe('https://code-api.example.com/download/session-1/file-1');
|
||||
expect(callConfig.responseType).toBe('stream');
|
||||
expect(callConfig.timeout).toBe(15000);
|
||||
expect(callConfig.headers['X-API-Key']).toBe('test-key');
|
||||
});
|
||||
|
||||
it('should throw on network error', async () => {
|
||||
mockAxios.mockRejectedValue(new Error('ECONNREFUSED'));
|
||||
|
||||
await expect(getCodeOutputDownloadStream('s/f', 'key')).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('uploadCodeEnvFile', () => {
|
||||
const baseUploadParams = {
|
||||
req: { user: { id: 'user-123' } },
|
||||
stream: Readable.from(['file-content']),
|
||||
filename: 'data.csv',
|
||||
apiKey: 'test-key',
|
||||
};
|
||||
|
||||
it('should pass dedicated keepAlive:false agents to axios', async () => {
|
||||
mockAxios.post.mockResolvedValue({
|
||||
data: {
|
||||
message: 'success',
|
||||
session_id: 'sess-1',
|
||||
files: [{ fileId: 'fid-1', filename: 'data.csv' }],
|
||||
},
|
||||
});
|
||||
|
||||
await uploadCodeEnvFile(baseUploadParams);
|
||||
|
||||
const callConfig = mockAxios.post.mock.calls[0][2];
|
||||
expect(callConfig.httpAgent).toBe(codeServerHttpAgent);
|
||||
expect(callConfig.httpsAgent).toBe(codeServerHttpsAgent);
|
||||
expect(callConfig.httpAgent).toBeInstanceOf(http.Agent);
|
||||
expect(callConfig.httpsAgent).toBeInstanceOf(https.Agent);
|
||||
expect(callConfig.httpAgent.keepAlive).toBe(false);
|
||||
expect(callConfig.httpsAgent.keepAlive).toBe(false);
|
||||
});
|
||||
|
||||
it('should set a timeout on upload requests', async () => {
|
||||
mockAxios.post.mockResolvedValue({
|
||||
data: {
|
||||
message: 'success',
|
||||
session_id: 'sess-1',
|
||||
files: [{ fileId: 'fid-1', filename: 'data.csv' }],
|
||||
},
|
||||
});
|
||||
|
||||
await uploadCodeEnvFile(baseUploadParams);
|
||||
|
||||
const callConfig = mockAxios.post.mock.calls[0][2];
|
||||
expect(callConfig.timeout).toBe(120000);
|
||||
});
|
||||
|
||||
it('should return fileIdentifier on success', async () => {
|
||||
mockAxios.post.mockResolvedValue({
|
||||
data: {
|
||||
message: 'success',
|
||||
session_id: 'sess-1',
|
||||
files: [{ fileId: 'fid-1', filename: 'data.csv' }],
|
||||
},
|
||||
});
|
||||
|
||||
const result = await uploadCodeEnvFile(baseUploadParams);
|
||||
expect(result).toBe('sess-1/fid-1');
|
||||
});
|
||||
|
||||
it('should append entity_id query param when provided', async () => {
|
||||
mockAxios.post.mockResolvedValue({
|
||||
data: {
|
||||
message: 'success',
|
||||
session_id: 'sess-1',
|
||||
files: [{ fileId: 'fid-1', filename: 'data.csv' }],
|
||||
},
|
||||
});
|
||||
|
||||
const result = await uploadCodeEnvFile({ ...baseUploadParams, entity_id: 'agent-42' });
|
||||
expect(result).toBe('sess-1/fid-1?entity_id=agent-42');
|
||||
});
|
||||
|
||||
it('should throw when server returns non-success message', async () => {
|
||||
mockAxios.post.mockResolvedValue({
|
||||
data: { message: 'quota_exceeded', session_id: 's', files: [] },
|
||||
});
|
||||
|
||||
await expect(uploadCodeEnvFile(baseUploadParams)).rejects.toThrow('quota_exceeded');
|
||||
});
|
||||
|
||||
it('should throw on network error', async () => {
|
||||
mockAxios.post.mockRejectedValue(new Error('ECONNREFUSED'));
|
||||
|
||||
await expect(uploadCodeEnvFile(baseUploadParams)).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,9 +1,15 @@
|
|||
const path = require('path');
|
||||
const { v4 } = require('uuid');
|
||||
const axios = require('axios');
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const { getCodeBaseURL } = require('@librechat/agents');
|
||||
const { logAxiosError, getBasePath, sanitizeFilename } = require('@librechat/api');
|
||||
const {
|
||||
getBasePath,
|
||||
logAxiosError,
|
||||
sanitizeFilename,
|
||||
createAxiosInstance,
|
||||
codeServerHttpAgent,
|
||||
codeServerHttpsAgent,
|
||||
} = require('@librechat/api');
|
||||
const {
|
||||
Tools,
|
||||
megabyte,
|
||||
|
|
@ -23,6 +29,8 @@ const { getStrategyFunctions } = require('~/server/services/Files/strategies');
|
|||
const { convertImage } = require('~/server/services/Files/images/convert');
|
||||
const { determineFileType } = require('~/server/utils');
|
||||
|
||||
const axios = createAxiosInstance();
|
||||
|
||||
/**
|
||||
* Creates a fallback download URL response when file cannot be processed locally.
|
||||
* Used when: file exceeds size limit, storage strategy unavailable, or download error occurs.
|
||||
|
|
@ -102,6 +110,8 @@ const processCodeOutput = async ({
|
|||
'User-Agent': 'LibreChat/1.0',
|
||||
'X-API-Key': apiKey,
|
||||
},
|
||||
httpAgent: codeServerHttpAgent,
|
||||
httpsAgent: codeServerHttpsAgent,
|
||||
timeout: 15000,
|
||||
});
|
||||
|
||||
|
|
@ -300,6 +310,8 @@ async function getSessionInfo(fileIdentifier, apiKey) {
|
|||
'User-Agent': 'LibreChat/1.0',
|
||||
'X-API-Key': apiKey,
|
||||
},
|
||||
httpAgent: codeServerHttpAgent,
|
||||
httpsAgent: codeServerHttpsAgent,
|
||||
timeout: 5000,
|
||||
});
|
||||
|
||||
|
|
@ -448,5 +460,6 @@ const primeFiles = async (options, apiKey) => {
|
|||
|
||||
module.exports = {
|
||||
primeFiles,
|
||||
getSessionInfo,
|
||||
processCodeOutput,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -36,11 +36,24 @@ jest.mock('uuid', () => ({
|
|||
v4: jest.fn(() => 'mock-uuid-1234'),
|
||||
}));
|
||||
|
||||
// Mock axios
|
||||
jest.mock('axios');
|
||||
const axios = require('axios');
|
||||
// Mock axios — process.js now uses createAxiosInstance() from @librechat/api
|
||||
const mockAxios = jest.fn();
|
||||
mockAxios.post = jest.fn();
|
||||
mockAxios.isAxiosError = jest.fn(() => false);
|
||||
|
||||
jest.mock('@librechat/api', () => {
|
||||
const http = require('http');
|
||||
const https = require('https');
|
||||
return {
|
||||
logAxiosError: jest.fn(),
|
||||
getBasePath: jest.fn(() => ''),
|
||||
sanitizeFilename: jest.fn((name) => name),
|
||||
createAxiosInstance: jest.fn(() => mockAxios),
|
||||
codeServerHttpAgent: new http.Agent({ keepAlive: false }),
|
||||
codeServerHttpsAgent: new https.Agent({ keepAlive: false }),
|
||||
};
|
||||
});
|
||||
|
||||
// Mock logger
|
||||
jest.mock('@librechat/data-schemas', () => ({
|
||||
logger: {
|
||||
warn: jest.fn(),
|
||||
|
|
@ -49,18 +62,10 @@ jest.mock('@librechat/data-schemas', () => ({
|
|||
},
|
||||
}));
|
||||
|
||||
// Mock getCodeBaseURL
|
||||
jest.mock('@librechat/agents', () => ({
|
||||
getCodeBaseURL: jest.fn(() => 'https://code-api.example.com'),
|
||||
}));
|
||||
|
||||
// Mock logAxiosError and getBasePath
|
||||
jest.mock('@librechat/api', () => ({
|
||||
logAxiosError: jest.fn(),
|
||||
getBasePath: jest.fn(() => ''),
|
||||
sanitizeFilename: jest.fn((name) => name),
|
||||
}));
|
||||
|
||||
// Mock models
|
||||
const mockClaimCodeFile = jest.fn();
|
||||
jest.mock('~/models', () => ({
|
||||
|
|
@ -90,14 +95,16 @@ jest.mock('~/server/utils', () => ({
|
|||
determineFileType: jest.fn(),
|
||||
}));
|
||||
|
||||
const http = require('http');
|
||||
const https = require('https');
|
||||
const { createFile, getFiles } = require('~/models');
|
||||
const { getStrategyFunctions } = require('~/server/services/Files/strategies');
|
||||
const { convertImage } = require('~/server/services/Files/images/convert');
|
||||
const { determineFileType } = require('~/server/utils');
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const { codeServerHttpAgent, codeServerHttpsAgent } = require('@librechat/api');
|
||||
|
||||
// Import after mocks
|
||||
const { processCodeOutput } = require('./process');
|
||||
const { processCodeOutput, getSessionInfo } = require('./process');
|
||||
|
||||
describe('Code Process', () => {
|
||||
const mockReq = {
|
||||
|
|
@ -145,7 +152,7 @@ describe('Code Process', () => {
|
|||
});
|
||||
|
||||
const smallBuffer = Buffer.alloc(100);
|
||||
axios.mockResolvedValue({ data: smallBuffer });
|
||||
mockAxios.mockResolvedValue({ data: smallBuffer });
|
||||
|
||||
const result = await processCodeOutput(baseParams);
|
||||
|
||||
|
|
@ -168,7 +175,7 @@ describe('Code Process', () => {
|
|||
});
|
||||
|
||||
const smallBuffer = Buffer.alloc(100);
|
||||
axios.mockResolvedValue({ data: smallBuffer });
|
||||
mockAxios.mockResolvedValue({ data: smallBuffer });
|
||||
|
||||
const result = await processCodeOutput(baseParams);
|
||||
|
||||
|
|
@ -182,7 +189,7 @@ describe('Code Process', () => {
|
|||
it('should process image files using convertImage', async () => {
|
||||
const imageParams = { ...baseParams, name: 'chart.png' };
|
||||
const imageBuffer = Buffer.alloc(500);
|
||||
axios.mockResolvedValue({ data: imageBuffer });
|
||||
mockAxios.mockResolvedValue({ data: imageBuffer });
|
||||
|
||||
const convertedFile = {
|
||||
filepath: '/uploads/converted-image.webp',
|
||||
|
|
@ -212,7 +219,7 @@ describe('Code Process', () => {
|
|||
});
|
||||
|
||||
const imageBuffer = Buffer.alloc(500);
|
||||
axios.mockResolvedValue({ data: imageBuffer });
|
||||
mockAxios.mockResolvedValue({ data: imageBuffer });
|
||||
convertImage.mockResolvedValue({ filepath: '/images/user-123/existing-img-id.webp' });
|
||||
|
||||
const result = await processCodeOutput(imageParams);
|
||||
|
|
@ -235,7 +242,7 @@ describe('Code Process', () => {
|
|||
describe('non-image file processing', () => {
|
||||
it('should process non-image files using saveBuffer', async () => {
|
||||
const smallBuffer = Buffer.alloc(100);
|
||||
axios.mockResolvedValue({ data: smallBuffer });
|
||||
mockAxios.mockResolvedValue({ data: smallBuffer });
|
||||
|
||||
const mockSaveBuffer = jest.fn().mockResolvedValue('/uploads/saved-file.txt');
|
||||
getStrategyFunctions.mockReturnValue({ saveBuffer: mockSaveBuffer });
|
||||
|
|
@ -256,7 +263,7 @@ describe('Code Process', () => {
|
|||
|
||||
it('should detect MIME type from buffer', async () => {
|
||||
const smallBuffer = Buffer.alloc(100);
|
||||
axios.mockResolvedValue({ data: smallBuffer });
|
||||
mockAxios.mockResolvedValue({ data: smallBuffer });
|
||||
determineFileType.mockResolvedValue({ mime: 'application/pdf' });
|
||||
|
||||
const result = await processCodeOutput({ ...baseParams, name: 'document.pdf' });
|
||||
|
|
@ -267,7 +274,7 @@ describe('Code Process', () => {
|
|||
|
||||
it('should fallback to application/octet-stream for unknown types', async () => {
|
||||
const smallBuffer = Buffer.alloc(100);
|
||||
axios.mockResolvedValue({ data: smallBuffer });
|
||||
mockAxios.mockResolvedValue({ data: smallBuffer });
|
||||
determineFileType.mockResolvedValue(null);
|
||||
|
||||
const result = await processCodeOutput({ ...baseParams, name: 'unknown.xyz' });
|
||||
|
|
@ -282,7 +289,7 @@ describe('Code Process', () => {
|
|||
fileSizeLimitConfig.value = 1000; // 1KB limit
|
||||
|
||||
const largeBuffer = Buffer.alloc(5000); // 5KB - exceeds 1KB limit
|
||||
axios.mockResolvedValue({ data: largeBuffer });
|
||||
mockAxios.mockResolvedValue({ data: largeBuffer });
|
||||
|
||||
const result = await processCodeOutput(baseParams);
|
||||
|
||||
|
|
@ -300,7 +307,7 @@ describe('Code Process', () => {
|
|||
describe('fallback behavior', () => {
|
||||
it('should fallback to download URL when saveBuffer is not available', async () => {
|
||||
const smallBuffer = Buffer.alloc(100);
|
||||
axios.mockResolvedValue({ data: smallBuffer });
|
||||
mockAxios.mockResolvedValue({ data: smallBuffer });
|
||||
getStrategyFunctions.mockReturnValue({ saveBuffer: null });
|
||||
|
||||
const result = await processCodeOutput(baseParams);
|
||||
|
|
@ -313,7 +320,7 @@ describe('Code Process', () => {
|
|||
});
|
||||
|
||||
it('should fallback to download URL on axios error', async () => {
|
||||
axios.mockRejectedValue(new Error('Network error'));
|
||||
mockAxios.mockRejectedValue(new Error('Network error'));
|
||||
|
||||
const result = await processCodeOutput(baseParams);
|
||||
|
||||
|
|
@ -327,7 +334,7 @@ describe('Code Process', () => {
|
|||
describe('usage counter increment', () => {
|
||||
it('should set usage to 1 for new files', async () => {
|
||||
const smallBuffer = Buffer.alloc(100);
|
||||
axios.mockResolvedValue({ data: smallBuffer });
|
||||
mockAxios.mockResolvedValue({ data: smallBuffer });
|
||||
|
||||
const result = await processCodeOutput(baseParams);
|
||||
|
||||
|
|
@ -341,7 +348,7 @@ describe('Code Process', () => {
|
|||
createdAt: '2024-01-01',
|
||||
});
|
||||
const smallBuffer = Buffer.alloc(100);
|
||||
axios.mockResolvedValue({ data: smallBuffer });
|
||||
mockAxios.mockResolvedValue({ data: smallBuffer });
|
||||
|
||||
const result = await processCodeOutput(baseParams);
|
||||
|
||||
|
|
@ -354,7 +361,7 @@ describe('Code Process', () => {
|
|||
createdAt: '2024-01-01',
|
||||
});
|
||||
const smallBuffer = Buffer.alloc(100);
|
||||
axios.mockResolvedValue({ data: smallBuffer });
|
||||
mockAxios.mockResolvedValue({ data: smallBuffer });
|
||||
|
||||
const result = await processCodeOutput(baseParams);
|
||||
|
||||
|
|
@ -365,7 +372,7 @@ describe('Code Process', () => {
|
|||
describe('metadata and file properties', () => {
|
||||
it('should include fileIdentifier in metadata', async () => {
|
||||
const smallBuffer = Buffer.alloc(100);
|
||||
axios.mockResolvedValue({ data: smallBuffer });
|
||||
mockAxios.mockResolvedValue({ data: smallBuffer });
|
||||
|
||||
const result = await processCodeOutput(baseParams);
|
||||
|
||||
|
|
@ -376,7 +383,7 @@ describe('Code Process', () => {
|
|||
|
||||
it('should set correct context for code-generated files', async () => {
|
||||
const smallBuffer = Buffer.alloc(100);
|
||||
axios.mockResolvedValue({ data: smallBuffer });
|
||||
mockAxios.mockResolvedValue({ data: smallBuffer });
|
||||
|
||||
const result = await processCodeOutput(baseParams);
|
||||
|
||||
|
|
@ -385,7 +392,7 @@ describe('Code Process', () => {
|
|||
|
||||
it('should include toolCallId and messageId in result', async () => {
|
||||
const smallBuffer = Buffer.alloc(100);
|
||||
axios.mockResolvedValue({ data: smallBuffer });
|
||||
mockAxios.mockResolvedValue({ data: smallBuffer });
|
||||
|
||||
const result = await processCodeOutput(baseParams);
|
||||
|
||||
|
|
@ -395,7 +402,7 @@ describe('Code Process', () => {
|
|||
|
||||
it('should call createFile with upsert enabled', async () => {
|
||||
const smallBuffer = Buffer.alloc(100);
|
||||
axios.mockResolvedValue({ data: smallBuffer });
|
||||
mockAxios.mockResolvedValue({ data: smallBuffer });
|
||||
|
||||
await processCodeOutput(baseParams);
|
||||
|
||||
|
|
@ -408,5 +415,36 @@ describe('Code Process', () => {
|
|||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('socket pool isolation', () => {
|
||||
it('should pass dedicated keepAlive:false agents to axios for processCodeOutput', async () => {
|
||||
const smallBuffer = Buffer.alloc(100);
|
||||
mockAxios.mockResolvedValue({ data: smallBuffer });
|
||||
|
||||
await processCodeOutput(baseParams);
|
||||
|
||||
const callConfig = mockAxios.mock.calls[0][0];
|
||||
expect(callConfig.httpAgent).toBe(codeServerHttpAgent);
|
||||
expect(callConfig.httpsAgent).toBe(codeServerHttpsAgent);
|
||||
expect(callConfig.httpAgent).toBeInstanceOf(http.Agent);
|
||||
expect(callConfig.httpsAgent).toBeInstanceOf(https.Agent);
|
||||
expect(callConfig.httpAgent.keepAlive).toBe(false);
|
||||
expect(callConfig.httpsAgent.keepAlive).toBe(false);
|
||||
});
|
||||
|
||||
it('should pass dedicated keepAlive:false agents to axios for getSessionInfo', async () => {
|
||||
mockAxios.mockResolvedValue({
|
||||
data: [{ name: 'sess/fid', lastModified: new Date().toISOString() }],
|
||||
});
|
||||
|
||||
await getSessionInfo('sess/fid', 'api-key');
|
||||
|
||||
const callConfig = mockAxios.mock.calls[0][0];
|
||||
expect(callConfig.httpAgent).toBe(codeServerHttpAgent);
|
||||
expect(callConfig.httpsAgent).toBe(codeServerHttpsAgent);
|
||||
expect(callConfig.httpAgent.keepAlive).toBe(false);
|
||||
expect(callConfig.httpsAgent.keepAlive).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
11
packages/api/src/utils/code.ts
Normal file
11
packages/api/src/utils/code.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import http from 'http';
|
||||
import https from 'https';
|
||||
|
||||
/**
|
||||
* Dedicated agents for code-server requests, preventing socket pool contamination.
|
||||
* follow-redirects (used by axios) leaks `socket.destroy` as a timeout listener;
|
||||
* on Node 19+ (keepAlive: true by default), tainted sockets re-enter the global pool
|
||||
* and kill unrelated requests (e.g., node-fetch in CodeExecutor) after the idle timeout.
|
||||
*/
|
||||
export const codeServerHttpAgent = new http.Agent({ keepAlive: false });
|
||||
export const codeServerHttpsAgent = new https.Agent({ keepAlive: false });
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
export * from './axios';
|
||||
export * from './azure';
|
||||
export * from './code';
|
||||
export * from './common';
|
||||
export * from './content';
|
||||
export * from './email';
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue