Skip to content

Commit d9fa9a9

Browse files
authored
Merge pull request #168 from devsapp/fix/list-large-output-and-cli-endpoint
fix: list 结果过大导致 CLI 崩溃 & --endpoint 命令行参数不生效
2 parents a67635b + b98fff2 commit d9fa9a9

7 files changed

Lines changed: 287 additions & 15 deletions

File tree

__tests__/ut/commands/list_test.ts

Lines changed: 104 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import List from '../../../src/subCommands/list';
22
import FC from '../../../src/resources/fc';
33
import { IInputs } from '../../../src/interface';
4-
import { tableShow } from '../../../src/utils';
4+
import { isAppCenter, tableShow } from '../../../src/utils';
5+
import logger from '../../../src/logger';
56

67
// Mock dependencies
78
jest.mock('../../../src/resources/fc');
@@ -12,17 +13,24 @@ jest.mock('../../../src/logger', () => ({
1213
error: jest.fn(),
1314
warn: jest.fn(),
1415
log: jest.fn(),
16+
write: jest.fn(),
1517
}));
16-
jest.mock('../../../src/utils', () => ({
17-
tableShow: jest.fn(),
18-
isAppCenter: jest.fn(),
19-
getUserAgent: jest.fn((userAgent, command) => {
20-
return (
21-
userAgent ||
22-
`Component:fc3;Nodejs:${process.version};OS:${process.platform}-${process.arch};command:${command}`
23-
);
24-
}),
25-
}));
18+
jest.mock('../../../src/utils', () => {
19+
const actual = jest.requireActual('../../../src/utils');
20+
return {
21+
tableShow: jest.fn(),
22+
isAppCenter: jest.fn(),
23+
getUserAgent: jest.fn((userAgent, command) => {
24+
return (
25+
userAgent ||
26+
`Component:fc3;Nodejs:${process.version};OS:${process.platform}-${process.arch};command:${command}`
27+
);
28+
}),
29+
MAX_DEFAULT_RENDER_LINES: actual.MAX_DEFAULT_RENDER_LINES,
30+
estimateRenderLines: actual.estimateRenderLines,
31+
isDefaultRenderOutput: actual.isDefaultRenderOutput,
32+
};
33+
});
2634

2735
describe('List', () => {
2836
let list: List;
@@ -237,6 +245,91 @@ describe('List', () => {
237245
});
238246
});
239247

248+
describe('run - output too large for the default renderer', () => {
249+
// 每个函数约 6 个字段,2 万个函数 > MAX_DEFAULT_RENDER_LINES(5w) 行
250+
const hugeFunctionsArray = Array.from({ length: 20000 }, (_v, i) => ({
251+
functionName: `test-func-${i}`,
252+
runtime: 'nodejs18',
253+
handler: 'index.handler',
254+
memorySize: 128,
255+
state: 'Active',
256+
lastModifiedTime: '2024-01-01T00:00:00Z',
257+
}));
258+
259+
let originalArgv: string[];
260+
261+
beforeEach(() => {
262+
originalArgv = process.argv;
263+
// clearAllMocks 不会清掉 mockReturnValue,这里显式回到默认值
264+
(isAppCenter as jest.Mock).mockReturnValue(false);
265+
});
266+
267+
afterEach(() => {
268+
process.argv = originalArgv;
269+
});
270+
271+
it('should print raw JSON instead of returning it when the default output format is used', async () => {
272+
process.argv = ['node', 's', 'cli', 'fc3', 'list'];
273+
mockInputs.args = [];
274+
list = new List(mockInputs);
275+
mockFcSdk.listFunctions = jest.fn().mockResolvedValue(hugeFunctionsArray);
276+
277+
const result = await list.run();
278+
expect(result).toBeUndefined();
279+
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('20000 functions'));
280+
expect(logger.write).toHaveBeenCalledWith(
281+
JSON.stringify({ functions: hugeFunctionsArray }, null, 2),
282+
);
283+
});
284+
285+
it('should return the result untouched when an output format is specified', async () => {
286+
process.argv = ['node', 's', 'cli', 'fc3', 'list', '-o', 'json'];
287+
mockInputs.args = [];
288+
list = new List(mockInputs);
289+
mockFcSdk.listFunctions = jest.fn().mockResolvedValue(hugeFunctionsArray);
290+
291+
const result = await list.run();
292+
expect(result).toEqual({ functions: hugeFunctionsArray });
293+
expect(logger.write).not.toHaveBeenCalled();
294+
});
295+
296+
it('should return the result untouched when it is small enough to render', async () => {
297+
process.argv = ['node', 's', 'cli', 'fc3', 'list'];
298+
mockInputs.args = [];
299+
list = new List(mockInputs);
300+
mockFcSdk.listFunctions = jest.fn().mockResolvedValue(mockFunctionsArray);
301+
302+
const result = await list.run();
303+
expect(result).toEqual({ functions: mockFunctionsArray });
304+
expect(logger.write).not.toHaveBeenCalled();
305+
});
306+
307+
it('should return the result untouched for programmatic app center callers', async () => {
308+
process.argv = ['node', 's', 'cli', 'fc3', 'list'];
309+
(isAppCenter as jest.Mock).mockReturnValue(true);
310+
mockInputs.args = [];
311+
list = new List(mockInputs);
312+
mockFcSdk.listFunctions = jest.fn().mockResolvedValue(hugeFunctionsArray);
313+
314+
const result = await list.run();
315+
expect(result).toEqual({ functions: hugeFunctionsArray });
316+
expect(logger.write).not.toHaveBeenCalled();
317+
});
318+
319+
it('should also guard the single page path', async () => {
320+
process.argv = ['node', 's', 'cli', 'fc3', 'list'];
321+
mockInputs.args = ['--limit', '20000'];
322+
list = new List(mockInputs);
323+
mockFcSdk.listFunctionsPage = jest
324+
.fn()
325+
.mockResolvedValue({ functions: hugeFunctionsArray, nextToken: 'next' });
326+
327+
const result = await list.run();
328+
expect(result).toBeUndefined();
329+
expect(logger.write).toHaveBeenCalled();
330+
});
331+
});
332+
240333
describe('run - error handling', () => {
241334
it('should propagate auto-pagination API errors', async () => {
242335
mockInputs.args = [];

__tests__/ut/core/base_test.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,32 @@ describe('Base', () => {
8686
// Logger is mocked, so we can't verify specific calls
8787
});
8888

89+
it('should take endpoint from command line args', async () => {
90+
mockInputs.args = ['--endpoint', 'http://127.0.0.1:8080'];
91+
92+
await base.handlePreRun(mockInputs, false);
93+
94+
expect(mockInputs.props.endpoint).toBe('http://127.0.0.1:8080');
95+
});
96+
97+
it('should let command line endpoint win over yaml props', async () => {
98+
mockInputs.props.endpoint = 'https://fcv3.cn-hangzhou.aliyuncs.com';
99+
mockInputs.args = ['--endpoint', 'http://127.0.0.1:8080'];
100+
101+
await base.handlePreRun(mockInputs, false);
102+
103+
expect(mockInputs.props.endpoint).toBe('http://127.0.0.1:8080');
104+
});
105+
106+
it('should keep yaml endpoint when no endpoint arg is given', async () => {
107+
mockInputs.props.endpoint = 'https://fcv3.cn-hangzhou.aliyuncs.com';
108+
mockInputs.args = [];
109+
110+
await base.handlePreRun(mockInputs, false);
111+
112+
expect(mockInputs.props.endpoint).toBe('https://fcv3.cn-hangzhou.aliyuncs.com');
113+
});
114+
89115
it('should trim image whitespace for custom container', async () => {
90116
mockInputs.props.customContainerConfig = {
91117
image: ' test-image:latest ',

__tests__/ut/utils/utils_functions_test.ts

Lines changed: 70 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,11 @@
1-
import { isAuto, isAutoVpcConfig, sleep } from '../../../src/utils/index';
1+
import {
2+
MAX_DEFAULT_RENDER_LINES,
3+
estimateRenderLines,
4+
isAuto,
5+
isAutoVpcConfig,
6+
isDefaultRenderOutput,
7+
sleep,
8+
} from '../../../src/utils/index';
29
import { computeLocalAuto } from '../../../src/resources/fc/impl/utils';
310
import log from '../../../src/logger';
411
log._set(console);
@@ -147,6 +154,68 @@ describe('Utils functions', () => {
147154
});
148155
});
149156

157+
describe('isDefaultRenderOutput', () => {
158+
it('should return true when no output format flag is present', () => {
159+
expect(isDefaultRenderOutput(['cli', 'fc3', 'list', '--region', 'cn-hangzhou'])).toBe(true);
160+
});
161+
162+
it('should return false for -o/--output-format/--output/--output-file', () => {
163+
expect(isDefaultRenderOutput(['list', '-o', 'json'])).toBe(false);
164+
expect(isDefaultRenderOutput(['list', '--output-format', 'yaml'])).toBe(false);
165+
expect(isDefaultRenderOutput(['list', '--output', 'raw'])).toBe(false);
166+
expect(isDefaultRenderOutput(['list', '--output-file', './out.json'])).toBe(false);
167+
});
168+
169+
it('should recognize flags written as --flag=value', () => {
170+
expect(isDefaultRenderOutput(['list', '--output-format=json'])).toBe(false);
171+
});
172+
173+
it('should not confuse a value that looks like a flag name', () => {
174+
expect(isDefaultRenderOutput(['list', '--prefix', 'output'])).toBe(true);
175+
});
176+
});
177+
178+
describe('estimateRenderLines', () => {
179+
it('should count one line per scalar field', () => {
180+
expect(estimateRenderLines({ a: 1, b: 'x', c: null })).toBe(3);
181+
});
182+
183+
it('should count nested objects and arrays', () => {
184+
// functionName + nasConfig + nasConfig.groupId + nasConfig.mountPoints
185+
// + 2 mount points, each with a separator line
186+
expect(
187+
estimateRenderLines({
188+
functionName: 'f',
189+
nasConfig: { groupId: 1, mountPoints: [{ mountDir: '/mnt' }, { mountDir: '/data' }] },
190+
}),
191+
).toBe(8);
192+
});
193+
194+
it('should count scalars in an array as one line each', () => {
195+
expect(estimateRenderLines(['a', 'b', 'c'])).toBe(3);
196+
});
197+
198+
it('should count the separator line prettyjson adds per object in an array', () => {
199+
// prettyjson 对 [{ a: 1 }, { a: 2 }] 输出 4 行,每个元素的字段 1 行 + 分隔 1 行
200+
expect(estimateRenderLines([{ a: 1 }, { a: 2 }])).toBe(4);
201+
expect(estimateRenderLines([[1, 2, 3]])).toBe(4);
202+
});
203+
204+
it('should exceed the threshold for a listing that breaks the default renderer', () => {
205+
const functions = Array.from({ length: 20000 }, (_v, i) => ({
206+
functionName: `f-${i}`,
207+
runtime: 'nodejs18',
208+
handler: 'index.handler',
209+
}));
210+
expect(estimateRenderLines({ functions })).toBeGreaterThan(MAX_DEFAULT_RENDER_LINES);
211+
});
212+
213+
it('should stay under the threshold for a normal listing', () => {
214+
const functions = Array.from({ length: 100 }, (_v, i) => ({ functionName: `f-${i}` }));
215+
expect(estimateRenderLines({ functions })).toBeLessThan(MAX_DEFAULT_RENDER_LINES);
216+
});
217+
});
218+
150219
describe('sleep', () => {
151220
it('should resolve after specified time', async () => {
152221
const start = Date.now();

src/base.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
/* eslint-disable require-atomic-updates */
33
/* eslint-disable no-await-in-loop */
44
import _ from 'lodash';
5+
import { parseArgv } from '@serverless-devs/utils';
56
import { IInputs, INasConfig } from './interface';
67
// eslint-disable-next-line @typescript-eslint/no-shadow
78
import log from './logger';
@@ -30,6 +31,13 @@ export default class Base {
3031
// 在运行方法之前运行
3132
async handlePreRun(inputs: IInputs, needCredential: boolean) {
3233
log._set(this.logger);
34+
// --endpoint 只出现在命令行参数里(yaml 模式下走 props.endpoint),
35+
// s cli 模式没有 yaml,必须从 argv 取,命令行优先级高于 yaml
36+
const argvEndpoint = _.get(parseArgv(inputs.args || [], { string: ['endpoint'] }), 'endpoint');
37+
if (!_.isEmpty(argvEndpoint)) {
38+
log.debug(`use endpoint from command line: ${argvEndpoint}`);
39+
_.set(inputs, 'props.endpoint', argvEndpoint);
40+
}
3341
// fc组件镜像 trim 左右空格
3442
const image = _.get(inputs, 'props.customContainerConfig.image');
3543
if (!_.isEmpty(image)) {

src/commands-help/list.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ Example:
2121
'[Optional] Specify the next token for pagination, only works with --limit',
2222
],
2323
['--table', '[Optional] Specify if output the result as table format'],
24+
['--endpoint <endpoint>', '[Optional] Specify the fc endpoint, e.g. http://192.168.1.1:8080'],
2425
],
2526
},
2627
};

src/subCommands/list/index.ts

Lines changed: 37 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,19 @@ import { IInputs, IRegion, checkRegion } from '../../interface';
33
import logger from '../../logger';
44
import _ from 'lodash';
55
import FC from '../../resources/fc';
6-
import { getUserAgent, tableShow } from '../../utils';
6+
import {
7+
MAX_DEFAULT_RENDER_LINES,
8+
estimateRenderLines,
9+
getUserAgent,
10+
isAppCenter,
11+
isDefaultRenderOutput,
12+
tableShow,
13+
} from '../../utils';
14+
15+
export interface IListResult {
16+
functions?: unknown[];
17+
nextToken?: string;
18+
}
719

820
const LIST_TABLE_KEYS = [
921
'functionName',
@@ -56,7 +68,7 @@ export default class List {
5668
tableShow(body.functions || [], LIST_TABLE_KEYS);
5769
return;
5870
}
59-
return body;
71+
return this.output(body);
6072
}
6173

6274
const functions = await this.fcSdk.listFunctions(prefix);
@@ -65,6 +77,28 @@ export default class List {
6577
tableShow(functions || [], LIST_TABLE_KEYS);
6678
return;
6779
}
68-
return { functions };
80+
return this.output({ functions });
81+
}
82+
83+
/**
84+
* 函数数量很多时,CLI 内核默认的 prettyjson 渲染器会因为参数个数超限抛
85+
* RangeError: Maximum call stack size exceeded,这里直接打印 JSON 兜底。
86+
* 只有真实 CLI 走 prettyjson 渲染,app center 等程序化调用方依赖返回值,原样返回。
87+
*/
88+
private output(result: IListResult): IListResult | undefined {
89+
if (
90+
isAppCenter() ||
91+
!isDefaultRenderOutput() ||
92+
estimateRenderLines(result) <= MAX_DEFAULT_RENDER_LINES
93+
) {
94+
return result;
95+
}
96+
97+
logger.warn(
98+
`Got ${
99+
(result.functions || []).length
100+
} functions, too large for the default output format. Printing raw JSON instead, use --limit/--next-token to paginate, --table for a summary, or -o json/yaml to pick the output format.`,
101+
);
102+
logger.write(JSON.stringify(result, null, 2));
69103
}
70104
}

src/utils/index.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -388,3 +388,44 @@ async function isZipFile(filePath: string): Promise<boolean> {
388388
return false;
389389
}
390390
}
391+
392+
// s CLI 默认输出格式下,内核用 prettyjson 渲染组件返回值。prettyjson 会把整个返回值
393+
// 拍平成一个字符串数组,再用 `push.apply(lines, subLines)` 回灌,参数个数超过 V8 上限时抛
394+
// RangeError: Maximum call stack size exceeded(本机 Node 22 实测 12w 个参数可以、13w 抛错)。
395+
// estimateRenderLines 是下界估算,用 prettyjson 1.2.5 实测:list 返回值偏低约 7%,
396+
// 最坏的结构形状(字段值是空数组/空对象)偏低 25%,即 5w 行阈值对应实际最多约 6.2w 行,
397+
// 距离 12w 的上限仍有充足余量。
398+
export const MAX_DEFAULT_RENDER_LINES = 50000;
399+
400+
/**
401+
* 返回值是否会走 CLI 内核的默认渲染器 (prettyjson)。
402+
* 指定 -o/--output-format/--output 时内核用 JSON/YAML 序列化,指定 --output-file 时写文件,
403+
* 都不经过 prettyjson。
404+
*/
405+
export function isDefaultRenderOutput(argv: string[] = process.argv.slice(2)): boolean {
406+
const outputFlags = ['-o', '--output-format', '--output', '--output-file'];
407+
return !argv.some((arg) => outputFlags.includes(arg.split('=')[0]));
408+
}
409+
410+
/**
411+
* 估算 prettyjson 渲染 data 需要的行数:每个字段一行,嵌套对象/数组的字段各自再算一行,
412+
* 数组里的对象/数组元素额外算一行(prettyjson 会给它们多输出一行分隔)。
413+
*/
414+
export function estimateRenderLines(data: any): number {
415+
if (_.isArray(data)) {
416+
return _.sum(
417+
data.map((item) =>
418+
_.isArray(item) || _.isPlainObject(item) ? estimateRenderLines(item) + 1 : 1,
419+
),
420+
);
421+
}
422+
if (_.isPlainObject(data)) {
423+
return _.sum(
424+
Object.values(data).map(
425+
(value) =>
426+
1 + (_.isArray(value) || _.isPlainObject(value) ? estimateRenderLines(value) : 0),
427+
),
428+
);
429+
}
430+
return 1;
431+
}

0 commit comments

Comments
 (0)