fix(07-10): 编解码自洽 —— bigint 长度字节守卫与参数计数取截断后长度

- WR-01:normalizeParam 的 bigint 分支按长度字节的真实容量(255 字节 = 2^2040)判定,|值| >= 2^2040 触发告警 152 并丢弃该参数;normalizeParamList 过滤被丢弃项,不保留占位
- WR-02:add/insert/set 写入的命令参数计数一律取标准化后的 normalizeParam.length,与 normalizeParamList 的 255 截断一致
- logger.json 的 warn 152 文案改为与真实可表示范围(2^2040 - 1)一致,并写明溢出参数会被忽略
- array.test.ts 新增 bigint 长度字节边界往返、超界丢弃后命令计数/读流对齐、256/300 参数截断计数回归
This commit is contained in:
unanmed 2026-09-17 12:53:27 +08:00
parent 1f9fb18cf5
commit 35d4ad6f1c
3 changed files with 134 additions and 12 deletions

View File

@ -636,6 +636,62 @@ describe('ReplayArray param codec', () => {
expect(info.map(v => v.code)).toContain(152);
});
// 验证 bigint 的长度字节边界值(2^2040 - 1)仍可完整读写且后续参数不错位
it('round-trips the largest bigint that fits the length byte', () => {
const array = createArray({
initParamLength: 512,
paramMaxLength: 4096
});
const value = 2n ** 2040n - 1n;
array.add(0, [value, 7]);
const params = array.get(0).params;
expectParamTyped(params[0], value);
expectParamTyped(params[1], 7);
expect(firstParamToken(array)).toBe(7);
// 长度字节为 255,与写入的幅值字节数一致
expect(new Uint8Array(array.getParamArray())[1]).toBe(255);
});
// 验证 2^2040 及以上的 bigint 触发告警码 152 并丢弃该参数,命令与其余参数仍完整写入且计数一致
it('drops the unrepresentable bigint param and keeps the rest of the command', () => {
for (const value of [
2n ** 2040n,
2n ** 2041n,
2n ** 2048n,
-(2n ** 2040n)
]) {
const array = createArray({
initParamLength: 512,
paramMaxLength: 4096
});
const { info } = logger.catch(() => array.add(0, [value, 7, 'x']));
expect(info.map(v => v.code)).toContain(152);
expect(array.length).toBe(1);
expect(array.get(0).command).toBe(0);
expect(array.get(0).params).toEqual([7, 'x']);
// 命令计数与实际写入的参数个数一致
expect(new Uint8Array(array.getCommandArray())[0]).toBe(2);
}
});
// 验证被丢弃的 bigint 参数不影响读流:后续命令仍从正确偏移解码
it('keeps the read stream aligned after dropping an unrepresentable bigint', () => {
const array = createArray({
initParamLength: 512,
paramMaxLength: 4096
});
logger.catch(() => array.add(1, [2n ** 2041n, 7]));
array.add(2, [20]);
const stream = array.createReadStream(0);
expectStepTyped(stream.read()!, 1, [7], 1);
expectStepTyped(stream.read()!, 2, [20], 2);
expect(stream.read()).toBeNull();
expect(stream.index).toBe(2);
});
// 验证单条命令参数超过 255 个时触发告警码 153 并忽略溢出参数
it('warns code 153 when a command exceeds 255 params', () => {
const array = createArray({
@ -648,6 +704,64 @@ describe('ReplayArray param codec', () => {
expect(info.map(v => v.code)).toContain(153);
});
// 验证 256 个参数的命令只写入截断后的计数 255,且后续命令仍能被读流正确读回
it('writes the truncated param count when a command exceeds 255 params', () => {
const array = createArray({
initParamLength: 1024,
paramMaxLength: 4096
});
const params = new Array<number>(256).fill(0);
logger.catch(() => array.add(0, params));
array.add(1, [9]);
expect(new Uint8Array(array.getCommandArray())[0]).toBe(255);
expect(array.get(0).params.length).toBe(255);
const stream = array.createReadStream(0);
const first = stream.read()!;
expect(first.command).toBe(0);
expect(first.params.length).toBe(255);
expectStepTyped(stream.read()!, 1, [9], 2);
expect(stream.read()).toBeNull();
});
// 验证 insert 与 set 同样写入截断后的参数计数,其后命令仍能正确读回
it('writes the truncated param count through insert and set', () => {
const params = new Array<number>(300).fill(0);
const inserted = createArray({
initParamLength: 1024,
paramMaxLength: 4096
});
inserted.add(1, [9]);
logger.catch(() => inserted.insert(1, 2, params));
inserted.add(3, [8]);
expect(new Uint8Array(inserted.getCommandArray())[2]).toBe(255);
expect(inserted.get(1).params.length).toBe(255);
const insertedStream = inserted.createReadStream(0);
expectStepTyped(insertedStream.read()!, 1, [9], 1);
expect(insertedStream.read()!.params.length).toBe(255);
expectStepTyped(insertedStream.read()!, 3, [8], 3);
expect(insertedStream.read()).toBeNull();
const replaced = createArray({
initParamLength: 1024,
paramMaxLength: 4096
});
replaced.add(1, [9]);
replaced.add(2, [20]);
logger.catch(() => replaced.set(0, 2, params));
expect(new Uint8Array(replaced.getCommandArray())[0]).toBe(255);
expect(replaced.get(0).params.length).toBe(255);
const replacedStream = replaced.createReadStream(0);
expect(replacedStream.read()!.params.length).toBe(255);
expectStepTyped(replacedStream.read()!, 2, [20], 2);
expect(replacedStream.read()).toBeNull();
});
});
describe('ReplayArray stream and buffer combination', () => {

View File

@ -221,8 +221,9 @@ export class ReplayArray implements IReplayArray {
/**
* 将数值标准化为数字或 Uint8Array
* @param param 参数值
* @returns 标准化结果,当参数无法用现有编码表示时返回 `null`
*/
private normalizeParam(param: ReplayParamValue): INormalizedParam {
private normalizeParam(param: ReplayParamValue): INormalizedParam | null {
if (typeof param === 'boolean') {
// 0 - boolean
return {
@ -267,11 +268,12 @@ export class ReplayArray implements IReplayArray {
};
} else if (typeof param === 'bigint') {
// 7 - 非负 bigint / 8 - 负 bigint,均以幅值按字节写入
const wall = 2n ** 2047n;
if (param > wall - 1n || param < -wall) {
logger.warn(152);
}
// 幅值长度只有一个字节,故幅值必须小于 2 ^ 2040(即 255 字节),否则长度字节会溢出
const magnitude = param < 0n ? -param : param;
if (magnitude >= 2n ** 2040n) {
logger.warn(152);
return null;
}
const bit = magnitude.toString(2);
const length = Math.ceil(bit.length / 8);
const arr = new Uint8Array(length);
@ -310,7 +312,7 @@ export class ReplayArray implements IReplayArray {
}
/**
* 将一系列参数值标准化为数字或 Uint8Array
* 将一系列参数值标准化为数字或 Uint8Array,无法表示的参数会被丢弃
* @param params 参数值列表
*/
private normalizeParamList(params: ReplayParamValue[]): INormalizedParam[] {
@ -319,7 +321,13 @@ export class ReplayArray implements IReplayArray {
logger.warn(153, params.length.toString());
arr = params.slice(0, 255);
}
return arr.map(v => this.normalizeParam(v));
// 丢弃的项不保留占位,保证命令参数计数与实际写入的参数个数一致
const normalized: INormalizedParam[] = [];
arr.forEach(v => {
const param = this.normalizeParam(v);
if (param) normalized.push(param);
});
return normalized;
}
/**
@ -423,7 +431,7 @@ export class ReplayArray implements IReplayArray {
// 追加指令
const commandSize = this.getCommandSize();
const commandStart = commandSize * this.length;
this.setCommandArray(commandStart, params.length, command);
this.setCommandArray(commandStart, normalized.length, command);
// 追加参数
this.setParamArray(this.paramUsed, normalized);
@ -454,7 +462,7 @@ export class ReplayArray implements IReplayArray {
// 插入到末尾等同于追加,此时无需任何位移
if (index === this.length) {
this.setCommandArray(commandStart, params.length, command);
this.setCommandArray(commandStart, normalized.length, command);
this.setParamArray(this.paramUsed, normalized);
this.indexArray[this.length] = this.paramUsed;
@ -472,7 +480,7 @@ export class ReplayArray implements IReplayArray {
this.indexArray.copyWithin(index + 1, index);
// 然后进行赋值操作,索引数组因为这一个指令的起始索引其实没变,所以不需要赋值
this.setCommandArray(commandStart, params.length, command);
this.setCommandArray(commandStart, normalized.length, command);
this.setParamArray(paramStart, normalized);
this.length++;
@ -551,7 +559,7 @@ export class ReplayArray implements IReplayArray {
// 先写入指令
const commandSize = this.getCommandSize();
const commandStart = index * commandSize;
this.setCommandArray(commandStart, params.length, command);
this.setCommandArray(commandStart, normalized.length, command);
// 然后根据差值位移参数数组,如果参数长度减少还需要将最后几项置零
this.paramArray.copyWithin(nextParam + deltaLength, nextParam);

View File

@ -226,7 +226,7 @@
"149": "Replay $1 array expand multiplier need to be greater than 1, but got $2.",
"150": "Replay $1 array reached its max length $2. Just provide higher value if you do need more space, or check your logic to avoid add replay command repeatly.",
"151": "0 or 1 is expected for boolean replay array, but got $1.",
"152": "Bigint replay param reached its limit, min for -(2n ** 2047n), and max for 2n ** 2047n - 1n.",
"152": "Bigint replay param out of representable range, min for -(2n ** 2040n - 1n), and max for 2n ** 2040n - 1n. The overflowed param will be ignored.",
"153": "Up to 255 parameters for a single replay command, but got $1. Overflowed parameters will be ignored.",
"154": "Cannot transfer replay command $1 from uint16 to uint8, since it's greater than 255.",
"155": "Replay read stream expired, ignore this warning if you do need read expired replay data, or check your replay reading logic.",