Skip to content

Commit 4ae45cc

Browse files
sunnylqmclaude
andcommitted
fix(hermes-base): stop rejecting a base over a printed property name
hermesc's BytecodeList.def annotates the string operand of DefineOwnByIdLong but not of DefineOwnById, so one instruction has two pretty renderings. A plain compile keeps small string ids and prints the bare id, which normalization resolved to the full name; a base compile inherits the base's string table, spills past 16 bits, takes the Long form and prints the text -- cut to hermesc's display budget, so `promotionRequestItemId` arrives as `"promotionRequestI"...`. Every property name longer than that budget therefore read as a difference and threw away a perfectly good base. Production has one such rejection (`Function<h> +72: DefineOwnById r3, r6, 1, "promotionRequestI"... vs "promotionRequestItemId"`), and the remaining eleven are the SwitchImm jump-table case already fixed in 12aba20 -- no rejection so far was real. The two renderings cannot be converted into each other: the display budget also truncates non-ASCII silently and without a marker (`ab中` prints as `"ab"`). So the operand is folded to `<str>` and the property name is compared by the raw audit, which decodes it from the binary string table in full -- STRING_OPERANDS already supplies the annotation hermesc is missing. An id that does not resolve still throws: that means the string table was not read at all. Tests use a real compiler: a base large enough to push the id past 16 bits reproduces the two renderings and must now compare equivalent, and two property names past the budget must still differ (caught by the raw pass). 40 fuzz rounds on seed 7: 40 equivalent, 4/4 planted differences caught. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 7da96bc commit 4ae45cc

4 files changed

Lines changed: 90 additions & 16 deletions

File tree

‎docs/hermes-base-verification.md‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,9 @@ v98 的 shape 索引和 offset 一样只用于定位(delta 可能重排 shape
3737

3838
为什么不能按 dump 的整段文本比:Hermes 的缓冲区构建器会**重叠/去重**序列化后的字面量——一个字面量的最后一个值字节可以同时是下一个字面量的 tag 字节(模糊测试实测:`61 52 | cd 09 b3 05 11`,前一段以 `[String 82]` 结尾,后一条指令的 offset 正指向 `52`)。顺序解析整段缓冲区(hermesc 的 dump 就是这么打印的)从这里开始失步,之后的条目全是噪声;delta 构建的 id 宽度不同,重叠位置也不同,于是两段"噪声"在某处不一致就被判为差异。2026-09-10 的 20 轮冒烟模糊测试里 3 次误杀全部源于此,改按指令比较后全部等价。无法读二进制缓冲区(文件结构不识别)时两侧一起比较整段文本,仅辅助诊断;即使文本相等也返回 `dump-failed` 并回退 plain,不能以丢失 offset/count 的文本确认等价。结果里 `literals: 'buffer'` 标明这一点。
3939

40-
`normalizeDisassemblyLine` 只折叠表示层差异:按指令解析后的字面量地址、已知宽度后缀、引号外的列对齐空白、switch 表的物理偏移(含经典 `SwitchImm`)、debug 偏移。字符串内部的连续空格、跳转目标标签、寄存器均保留。未知 string ID、无法解码的字面量、未知 buffer 操作数形态直接失败;两侧都无法解析也不等价。
40+
`normalizeDisassemblyLine` 只折叠表示层差异:按指令解析后的字面量地址、已知宽度后缀、引号外的列对齐空白、switch 表的物理偏移(含经典 `SwitchImm`)、debug 偏移、`DefineOwnById*` 的字符串操作数。字符串内部的连续空格、跳转目标标签、寄存器均保留。
41+
42+
**`DefineOwnById*` 的字符串操作数为什么只能丢给 raw**:Hermes 的 `BytecodeList.def` 只给 `DefineOwnByIdLong` 标了字符串操作数,短形式没标。于是同一条指令有两种 pretty 打印:普通编译 id 小、走短形式,打印**裸 id**;base 编译继承了 base 的字符串表,id 溢出 16 位后改用 Long 形式,打印**文本**,而 pretty 的文本又按显示预算截断(`promotionRequestItemId` 打成 `"promotionRequestI"...`)。两种表示互相还原不了——这个预算对非 ASCII 还会静默截断且不加 `...`(实测 `ab中` 打成 `"ab"`、`abcdefghijklmnop中` 打成 `"abcdefghi"`),所以还原出的完整名字永远等不上打印出来的名字。归一化因此把该操作数整个折成 `<str>`,属性名交给 raw 核对按二进制字符串表全量比较(`hermes-raw.ts` 的 `STRING_OPERANDS` 正是为此显式补了 `DefineOwnById`)。2026-09-22 之前这里会把任何超过显示预算的属性名判成差异,线上因此误杀过 base。未知 string ID、无法解码的字面量、未知 buffer 操作数形态直接失败;两侧都无法解析也不等价。
4143

4244
**原始操作数核对**:`hermes-raw.ts` 从 raw dump 读取指令起点和操作数类型,并检查操作数与 HBC 字节一致、指令覆盖完整函数体。字符串从 small/overflow string table 与 string storage 按完整 ASCII/UTF-16 code unit 解码;BigInt、正则和 double 读取真实字节(保留 `-0` 和尾部精度);函数引用保留索引,与顺序对齐的函数表共同检查,同名函数不能互换。地址映射为目标指令序号;整数和字符串 switch 从二进制恢复 case 值与目的地。函数运行时 flags 和参数/寄存器等字段也参与比较,剔除的仅是物理地址、debug presence 与 compact/overflow 表示。
4345

‎src/utils/hermes-base.ts‎

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1112,11 +1112,26 @@ export function normalizeDisassemblyLine(
11121112
if (m) return `${m[1]} ${m[3]}${normalizeOperandSpacing(m[4])}`;
11131113
}
11141114
if (opcode.startsWith('DefineOwnById')) {
1115-
m = /^(\s*DefineOwnById\w*\s+r\d+, r\d+, \d+, )(\d+)$/.exec(line);
1115+
// BytecodeList.def annotates the string operand of DefineOwnByIdLong but
1116+
// not of DefineOwnById, so one instruction has two pretty renderings: the
1117+
// plain compile keeps small ids and prints the bare id, while a base
1118+
// compile inherits the base's string table, spills past 16 bits, picks the
1119+
// Long form and prints the *text* — cut to hermesc's display budget
1120+
// (`"promotionRequestI"...` for `promotionRequestItemId`). Neither side can
1121+
// be turned into the other: that budget also drops non-ASCII silently and
1122+
// without a marker (`ab\u4e2d` prints as `"ab"`), so a resolved name never
1123+
// equals a printed one. The operand is folded away here and the property
1124+
// name is compared by the raw audit, which decodes it from the string
1125+
// table in full (STRING_OPERANDS supplies the missing annotation).
1126+
m = /^(\s*DefineOwnById\w*\s+r\d+, r\d+, \d+, )(\d+)?.*$/.exec(line);
11161127
if (m) {
1117-
const text = strings.get(Number(m[2]));
1118-
if (text === undefined) throw new Error(`unresolved string id ${m[2]}`);
1119-
line = `${m[1]}${JSON.stringify(text)}`;
1128+
// An id form whose id is not in the table means the string table was
1129+
// not read at all; that still fails closed rather than folding an
1130+
// operand nothing could resolve.
1131+
if (m[2] !== undefined && !strings.has(Number(m[2]))) {
1132+
throw new Error(`unresolved string id ${m[2]}`);
1133+
}
1134+
line = `${m[1]}<str>`;
11201135
}
11211136
}
11221137
// Operand-width variants of one instruction (GetByIdShort/GetById/GetByIdLong,

‎tests/hermes-base.test.ts‎

Lines changed: 28 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -662,9 +662,22 @@ describe('helpers', () => {
662662
expect(
663663
normalizeDisassemblyLine(' JStrictEqualLong L12, r1, r2', strings),
664664
).toBe(' JStrictEqual L12, r1, r2');
665+
// hermesc prints the string id for DefineOwnById and the (display
666+
// truncated) text for DefineOwnByIdLong, which a base compile picks as
667+
// soon as the inherited string table pushes the id past 16 bits. Both
668+
// renderings fold to one operand; the raw audit compares the name itself.
665669
expect(
666670
normalizeDisassemblyLine(' DefineOwnById r7, r8, 2, 11591', strings),
667-
).toBe(' DefineOwnById r7, r8, 2, "foo"');
671+
).toBe(' DefineOwnById r7, r8, 2, <str>');
672+
expect(
673+
normalizeDisassemblyLine(
674+
' DefineOwnByIdLong r7, r8, 2, "promotionRequestI"...',
675+
strings,
676+
),
677+
).toBe(' DefineOwnById r7, r8, 2, <str>');
678+
expect(() =>
679+
normalizeDisassemblyLine(' DefineOwnById r7, r8, 2, 4242', strings),
680+
).toThrow('unresolved string id 4242');
668681
expect(
669682
normalizeDisassemblyLine('Offset in debug table: source 0x0000', strings),
670683
).toBeNull();
@@ -831,10 +844,7 @@ describe('publish metadata never sends JSON null', () => {
831844
});
832845

833846
/** the regex-per-line implementation the fast path replaced; must agree */
834-
function legacyNormalize(
835-
line: string,
836-
strings: Map<number, string>,
837-
): string | null {
847+
function legacyNormalize(line: string): string | null {
838848
if (/^Offset in debug table/.test(line)) return null;
839849
let m =
840850
/^(\s*New(?:Array|Object)WithBuffer)(?:Long)?(AndParent)?\s+(r\d+)(?:, (r\d+))?(.*)$/.exec(
@@ -847,8 +857,8 @@ function legacyNormalize(
847857
}
848858
m = /^(\s*J[A-Za-z]+?)(Long)?\s+(L\d+|\d+)(.*)$/.exec(line);
849859
if (m) return `${m[1]} ${m[3]}${m[4]}`;
850-
m = /^(\s*DefineOwnById\w*\s+r\d+, r\d+, \d+, )(\d+)$/.exec(line);
851-
if (m) line = `${m[1]}"${strings.get(Number(m[2])) ?? `?${m[2]}`}"`;
860+
m = /^(\s*DefineOwnById\w*\s+r\d+, r\d+, \d+, )(\d+)?.*$/.exec(line);
861+
if (m) line = `${m[1]}<str>`;
852862
m = /^(\s*)([A-Za-z]+?)(?:LongIndex|Long|Short)?(\s+.*|)$/.exec(line);
853863
if (m) line = `${m[1]}${m[2]}${m[3].replace(/\s+/g, ' ')}`;
854864
m = /^(\s*StringSwitchImm r\d+, \d+, )\d+(, L\d+, \d+)$/.exec(line);
@@ -882,6 +892,7 @@ describe('normalizeDisassemblyLine fast path', () => {
882892
' DefineOwnById r0, r1, 1, 3',
883893
' DefineOwnByIdLong r0, r1, 1, 42',
884894
' DefineOwnByIdShort r0, r1, 1, 7',
895+
' DefineOwnByIdLong r0, r1, 1, "a-name-past-the-p"...',
885896
' GetByIdShort r1, r0, 1, "foo"',
886897
' GetById r1, r0, 1, "foo"',
887898
' GetByIdLong r1, r0, 1, "foo"',
@@ -928,7 +939,7 @@ describe('normalizeDisassemblyLine fast path', () => {
928939
];
929940
for (const line of corpus) {
930941
expect(normalizeDisassemblyLine(line, strings)).toBe(
931-
legacyNormalize(line, strings),
942+
legacyNormalize(line),
932943
);
933944
}
934945
});
@@ -1124,7 +1135,13 @@ describe.if(os.platform() !== 'win32')(
11241135
expect(result.functions).toBe(0);
11251136
});
11261137

1127-
test('a resolved property name that differs is a difference, wherever the ids point', async () => {
1138+
test('a DefineOwnById property name is left to the raw audit, never to the text', async () => {
1139+
// hermesc prints this operand as a string id in one build and as
1140+
// display-truncated text in the other, so the pretty pass folds it
1141+
// away. A name that differs is caught by the raw audit against the
1142+
// binary string table; here the fake compiler has no readable HBC, so
1143+
// the pass ends unverifiable -- the base is dropped either way and the
1144+
// text alone never reports 'equivalent'.
11281145
const wrong = DELTA_DUMP.replace(
11291146
'DefineOwnByIdLong r1, r0, 1, 4',
11301147
'DefineOwnByIdLong r1, r0, 1, 3',
@@ -1134,9 +1151,9 @@ describe.if(os.platform() !== 'win32')(
11341151
write('delta.hbc', wrong),
11351152
write('plain.hbc', PLAIN_DUMP),
11361153
);
1137-
expect(result.status).toBe('different');
1154+
expect(result.status).toBe('dump-failed');
11381155
expect(result.detail).toBe(
1139-
'Function<global>(1 params, 3 registers, 0 symbols): +4: DefineOwnById r1, r0, 1, "baz" vs DefineOwnById r1, r0, 1, "foo"',
1156+
'unsupported or unreadable HBC layout; text-only comparison cannot verify equivalence',
11401157
);
11411158
});
11421159

‎tests/hermes-raw.test.ts‎

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -236,6 +236,46 @@ describe.if(hasHermesc)('lossless Hermes operand audit (real compiler)', () => {
236236
);
237237
});
238238

239+
// hermesc annotates the string operand of DefineOwnByIdLong but not of
240+
// DefineOwnById, so the same instruction prints the text in one build and a
241+
// bare id in the other -- and pretty output cuts that text to a display
242+
// budget. A base whose string table spills past 16 bits makes the delta
243+
// build take the Long form, which used to read as a difference and threw
244+
// away a good base for any property name longer than the budget.
245+
test('a wide DefineOwnById against a foreign base is not a difference', async () => {
246+
const base = compile(
247+
'wide-base',
248+
Array.from(
249+
{ length: 70000 },
250+
(_, i) => `globalThis.s${i} = "base string ${i}";`,
251+
).join('\n'),
252+
);
253+
const source =
254+
'globalThis.h = function h(s, v){ return {...s, promotionRequestItemId: v, b: 1}; };';
255+
const plain = compile('wide-plain', source);
256+
const delta = compile('wide-delta', source, base);
257+
const pretty = (file: string) =>
258+
dump(file, true)
259+
.split('\n')
260+
.filter((line) => line.includes('DefineOwnById'));
261+
// the renderings really are the two the fold has to bridge
262+
expect(pretty(delta)[0]).toContain('DefineOwnByIdLong');
263+
expect(pretty(delta)[0]).toContain('"promotionRequestI"...');
264+
expect(pretty(plain)[0]).not.toContain('promotionRequest');
265+
const result = await compareHermesBytecode(hermesc!, delta, plain);
266+
expect(result.status, result.detail).toBe('equivalent');
267+
}, 30_000);
268+
269+
test('a property name past the pretty limit still has to match', async () => {
270+
const object = (name: string) =>
271+
`globalThis.h = function h(s, v){ return {...s, ${name}: v}; };`;
272+
const a = compile('name-a', object('promotionRequestItemIdAlpha'));
273+
const b = compile('name-b', object('promotionRequestItemIdBeta'));
274+
const result = await compareHermesBytecode(hermesc!, a, b);
275+
expect(result.status).toBe('different');
276+
expect(result.detail).toContain('raw instruction');
277+
});
278+
239279
test('overflow function headers retain the same runtime fields', async () => {
240280
const base = compile('header-base', 'globalThis.old = "older strings";');
241281
const params = Array.from({ length: 140 }, (_, i) => `p${i}`).join(',');

0 commit comments

Comments
 (0)