Skip to content

Commit 20eaecb

Browse files
test(hermes-base): isolate late errors and preserve spacing semantics
1 parent 67259bf commit 20eaecb

6 files changed

Lines changed: 219 additions & 40 deletions

File tree

src/utils/hermes-base.ts

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1150,21 +1150,31 @@ export function normalizeDisassemblyLine(
11501150
return line;
11511151
}
11521152

1153-
/** Only formatting outside quoted operands may be collapsed. */
1153+
/**
1154+
* Collapse formatting outside quoted operands without altering literal code units.
1155+
* ASCII dump spacing takes the char-code fast path; uncommon Unicode characters
1156+
* retain the complete runtime `\s` semantics instead of a narrower space list.
1157+
*/
11541158
function normalizeOperandSpacing(text: string): string {
11551159
let result = '';
11561160
let quoted = false;
11571161
let escaped = false;
11581162
let spacing = false;
1159-
for (const char of text) {
1163+
for (let i = 0; i < text.length; i++) {
1164+
const char = text[i];
11601165
if (quoted) {
11611166
result += char;
11621167
if (escaped) escaped = false;
11631168
else if (char === '\\') escaped = true;
11641169
else if (char === '"') quoted = false;
11651170
continue;
11661171
}
1167-
if (/\s/.test(char)) {
1172+
const code = text.charCodeAt(i);
1173+
const whitespace =
1174+
code <= 0x7f
1175+
? isSpace(code) || (code >= 0x0a && code <= 0x0c)
1176+
: /\s/.test(char);
1177+
if (whitespace) {
11681178
if (!spacing) result += ' ';
11691179
spacing = true;
11701180
} else {
@@ -1343,6 +1353,7 @@ class DumpReader {
13431353
});
13441354
}
13451355

1356+
/** Consume lookahead first, then the stream; null is reserved for EOF. */
13461357
private async nextLine(): Promise<string | null> {
13471358
if (this.pending !== null) {
13481359
const line = this.pending;

src/utils/hermes-raw.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,15 @@ import { type LiteralBuffers, LiteralResolver } from './hermes-literals';
1616

1717
export class UnverifiableHermesBytecode extends Error {}
1818

19+
/** Fail closed on missing references rather than comparing equal placeholders. */
1920
function requireValue<T>(value: T | null | undefined, what: string): T {
2021
if (value === null || value === undefined) {
2122
throw new UnverifiableHermesBytecode(what);
2223
}
2324
return value;
2425
}
2526

27+
/** Bound a reference to its owning section before creating a zero-copy view. */
2628
function checkedSlice(data: Buffer, start: number, length: number): Buffer {
2729
if (
2830
!Number.isSafeInteger(start) ||
@@ -248,10 +250,15 @@ interface RawInstruction {
248250
size: number;
249251
}
250252

253+
/** Remove encoding-width suffixes; operand values remain part of the audit. */
251254
function foldWidth(opcode: string): string {
252255
return opcode.replace(/(?:LongIndex|Long|Short)$/, '');
253256
}
254257

258+
/**
259+
* Read raw operand boundaries and verify integer values against the HBC bytes.
260+
* Doubles use their exact bits, not the rounded number printed by hermesc.
261+
*/
255262
function parseRawInstruction(
256263
line: string,
257264
data: HermesSemanticData,
@@ -460,6 +467,7 @@ export interface RawAuditResult {
460467
detail?: string;
461468
}
462469

470+
/** Decode split UTF-8 sequences without buffering the whole raw dump. */
463471
async function* linesOf(stream: NodeJS.ReadableStream): AsyncGenerator<string> {
464472
const decoder = new StringDecoder('utf8');
465473
let rest = '';
@@ -476,7 +484,10 @@ async function* linesOf(stream: NodeJS.ReadableStream): AsyncGenerator<string> {
476484
if (rest) yield rest;
477485
}
478486

479-
/** A second, raw pass: no additional compile, and only one function in memory. */
487+
/**
488+
* Audit a pair of raw dumps while retaining one function per side plus HBC data.
489+
* Cancellation/errors fail closed; both subprocesses are terminated and reaped.
490+
*/
480491
export async function auditRawHermesBytecode(
481492
command: string,
482493
files: [string, string],
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
/**
2+
* Run async-error regressions outside the test runner under either Bun or Node.
3+
* Listeners remain installed until natural exit: even errors after the result
4+
* marker make the child fail. A parent deadline also detects leaked handles.
5+
*/
6+
const assert = require('node:assert/strict');
7+
const path = require('node:path');
8+
9+
const unexpected = [];
10+
/** Record both Promise rejections and uncaught ChildProcess error events. */
11+
function recordUnexpected(kind, error) {
12+
unexpected.push(kind);
13+
process.exitCode = 1;
14+
console.error(`HERMES_ASYNC_ERROR ${kind}: ${String(error)}`);
15+
}
16+
process.on('unhandledRejection', (error) =>
17+
recordUnexpected('unhandledRejection', error),
18+
);
19+
process.on('uncaughtException', (error) =>
20+
recordUnexpected('uncaughtException', error),
21+
);
22+
23+
/** Exercise the actual API, then allow timers and immediates to report errors. */
24+
async function main() {
25+
const config = JSON.parse(process.argv[2]);
26+
if (config.cwd) process.chdir(config.cwd);
27+
let result;
28+
if (config.operation === 'abort') {
29+
const { compareHermesBytecode } = require(path.resolve(config.modulePath));
30+
const controller = new AbortController();
31+
controller.abort();
32+
result = await compareHermesBytecode(
33+
config.command || process.execPath,
34+
'missing-a',
35+
'missing-b',
36+
{ signal: controller.signal, timeoutMs: 500 },
37+
);
38+
assert.equal(result.status, 'dump-failed');
39+
assert.match(result.detail, /abort/i);
40+
} else if (config.operation === 'compile') {
41+
const { compileHermesByteCode } = require(path.resolve(config.modulePath));
42+
result = await compileHermesByteCode(config.options);
43+
} else if (config.operation === 'control-rejection') {
44+
setImmediate(() => Promise.reject(new Error('intentional rejection')));
45+
} else if (config.operation === 'control-exception') {
46+
setImmediate(() => {
47+
throw new Error('intentional exception');
48+
});
49+
} else {
50+
throw new Error(`Unknown operation: ${config.operation}`);
51+
}
52+
// Checking only immediately after await can miss later-turn error delivery.
53+
await new Promise((resolve) => setTimeout(resolve, 0));
54+
await new Promise((resolve) => setImmediate(resolve));
55+
assert.deepEqual(unexpected, []);
56+
console.log(`HERMES_ASYNC_RESULT ${JSON.stringify(result)}`);
57+
}
58+
main().catch((error) => {
59+
console.error(error);
60+
process.exitCode = 1;
61+
});

tests/hermes-base-safety.test.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,3 +31,61 @@ describe('Hermes-base normalization must retain semantic differences', () => {
3131
);
3232
});
3333
});
34+
35+
/** Previous spacing implementation, kept as an independent compatibility oracle. */
36+
function referenceSpacing(text: string): string {
37+
let result = '';
38+
let quoted = false;
39+
let escaped = false;
40+
let spacing = false;
41+
for (const char of text) {
42+
if (quoted) {
43+
result += char;
44+
if (escaped) escaped = false;
45+
else if (char === '\\') escaped = true;
46+
else if (char === '"') quoted = false;
47+
continue;
48+
}
49+
if (/\s/.test(char)) {
50+
if (!spacing) result += ' ';
51+
spacing = true;
52+
} else {
53+
result += char;
54+
spacing = false;
55+
if (char === '"') quoted = true;
56+
}
57+
}
58+
return result;
59+
}
60+
61+
describe('Hermes operand spacing compatibility', () => {
62+
test('matches the previous whitespace behavior for every UTF-16 code unit', () => {
63+
const mismatches: number[] = [];
64+
for (let code = 0; code <= 0xffff; code++) {
65+
const operands = ` r0, ${String.fromCharCode(code)} r1`;
66+
if (
67+
normalize(` Mov${operands}`) !==
68+
` Mov${referenceSpacing(operands)}`
69+
) {
70+
mismatches.push(code);
71+
}
72+
}
73+
expect(mismatches).toEqual([]);
74+
});
75+
76+
test('preserves whitespace, escapes and surrogate pairs inside quotes', () => {
77+
const whitespace =
78+
'\t\n\v\f\r \u00a0\u1680\u2000\u2028\u2029\u202f\u205f\u3000\ufeff';
79+
const operands = [
80+
` r0, "a${whitespace}b"`,
81+
String.raw` r0, "a\" b\\ c", r1`,
82+
` r0, "😀 𠮷\ud800\udfff",\u00a0\ufeffr1`,
83+
' r0, \u0085\u180e\u200b r1',
84+
];
85+
for (const operand of operands) {
86+
expect(normalize(` Mov${operand}`)).toBe(
87+
` Mov${referenceSpacing(operand)}`,
88+
);
89+
}
90+
});
91+
});

tests/hermes-compile.test.ts

Lines changed: 34 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -285,30 +285,40 @@ exec "${hermesc}" "$@"
285285
);
286286
const map = path.join(outputFolder, `${bundleName}.map`);
287287
fs.writeFileSync(map, '{}');
288-
const cwd = process.cwd();
289-
const unhandled: unknown[] = [];
290-
const onUnhandled = (error: unknown) => unhandled.push(error);
291-
process.on('unhandledRejection', onUnhandled);
292-
process.chdir(dir);
293-
try {
294-
const result = await compileHermesByteCode({
295-
bundleName,
296-
outputFolder,
297-
sourcemapOutput: map,
298-
shouldCleanSourcemap: true,
299-
baseRequest: { option: baseHbc, verify: true },
300-
hermesCommand: wrapper,
301-
});
302-
expect(fs.existsSync(marker)).toBe(true);
303-
expect(result.outcome).toBe('dump-failed');
304-
expect(result.base).toBeNull();
305-
expect(unhandled).toEqual([]);
306-
expect(fs.readFileSync(map, 'utf8')).toBe('{}');
307-
expect(leftovers()).toEqual([]);
308-
} finally {
309-
process.chdir(cwd);
310-
process.off('unhandledRejection', onUnhandled);
311-
}
288+
const child = spawnSync(
289+
process.execPath,
290+
[
291+
path.join(__dirname, 'fixtures/hermes-async-check.cjs'),
292+
JSON.stringify({
293+
operation: 'compile',
294+
modulePath: require.resolve('../src/bundle-runner'),
295+
cwd: dir,
296+
options: {
297+
bundleName,
298+
outputFolder,
299+
sourcemapOutput: map,
300+
shouldCleanSourcemap: true,
301+
baseRequest: { option: baseHbc, verify: true },
302+
hermesCommand: wrapper,
303+
},
304+
}),
305+
],
306+
{ encoding: 'utf8', timeout: 4000 },
307+
);
308+
expect(child.error).toBeUndefined();
309+
expect(child.signal).toBeNull();
310+
expect(child.status).toBe(0);
311+
expect(child.stderr).not.toContain('HERMES_ASYNC_ERROR');
312+
const line = child.stdout
313+
.split('\n')
314+
.find((value) => value.startsWith('HERMES_ASYNC_RESULT '));
315+
expect(line).toBeDefined();
316+
const result = JSON.parse(line!.slice('HERMES_ASYNC_RESULT '.length));
317+
expect(fs.existsSync(marker)).toBe(true);
318+
expect(result.outcome).toBe('dump-failed');
319+
expect(result.base).toBeNull();
320+
expect(fs.readFileSync(map, 'utf8')).toBe('{}');
321+
expect(leftovers()).toEqual([]);
312322
}, 5000);
313323

314324
test('a selection started ahead of time is consumed by the compile', async () => {

tests/hermes-timeout.test.ts

Lines changed: 40 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
2+
import { spawnSync } from 'child_process';
23
import fs from 'fs-extra';
34
import os from 'os';
45
import path from 'path';
@@ -85,18 +86,45 @@ describe.if(process.platform !== 'win32')('Hermes subprocess deadlines', () => {
8586
}
8687
}, 2000);
8788

88-
test('an already-aborted request fails without an unhandled spawn error', async () => {
89-
const controller = new AbortController();
90-
controller.abort();
91-
const result = await compareHermesBytecode(
92-
hanging,
93-
'missing-a',
94-
'missing-b',
95-
{
96-
signal: controller.signal,
97-
timeoutMs: 500,
98-
},
89+
test('an already-aborted request has no late unhandled errors in an isolated process', () => {
90+
const child = spawnSync(
91+
process.execPath,
92+
[
93+
path.join(__dirname, 'fixtures/hermes-async-check.cjs'),
94+
JSON.stringify({
95+
operation: 'abort',
96+
modulePath: require.resolve('../src/utils/hermes-base'),
97+
command: hanging,
98+
}),
99+
],
100+
{ encoding: 'utf8', timeout: 1500 },
99101
);
100-
expect(result.status).toBe('dump-failed');
102+
expect(child.error).toBeUndefined();
103+
expect(child.signal).toBeNull();
104+
expect(child.status).toBe(0);
105+
expect(child.stderr).not.toContain('HERMES_ASYNC_ERROR');
106+
expect(child.stdout).toContain('"status":"dump-failed"');
101107
}, 2000);
102108
});
109+
110+
describe('isolated async-error observer negative controls', () => {
111+
for (const [operation, event] of [
112+
['control-rejection', 'unhandledRejection'],
113+
['control-exception', 'uncaughtException'],
114+
]) {
115+
test(`fails for a late ${event}`, () => {
116+
const child = spawnSync(
117+
process.execPath,
118+
[
119+
path.join(__dirname, 'fixtures/hermes-async-check.cjs'),
120+
JSON.stringify({ operation }),
121+
],
122+
{ encoding: 'utf8', timeout: 1500 },
123+
);
124+
expect(child.error).toBeUndefined();
125+
expect(child.signal).toBeNull();
126+
expect(child.status).toBe(1);
127+
expect(child.stderr).toContain(`HERMES_ASYNC_ERROR ${event}`);
128+
}, 2000);
129+
}
130+
});

0 commit comments

Comments
 (0)