Skip to content

Commit 7e6ef3e

Browse files
sunnylqmclaude
andcommitted
fix(hermes-base): integer Range deadlines; reject a non-HBC base from its head
- timeoutFor() returned a fractional ms count for entries over 64 KB, which Node's AbortSignal.timeout() rejects, so every range download fell back to the whole package. Round it up. - When the server does not know the base's HBC version, read the bundle's first 128 bytes (header + head in one small request, a truncated deflate stream inflated as far as it goes) and give up before fetching the body if it is plain JS or another HBC version. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent cd20265 commit 7e6ef3e

3 files changed

Lines changed: 403 additions & 24 deletions

File tree

‎src/utils/hermes-base.ts‎

Lines changed: 64 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import { webFetch } from './runtime';
3333
import { enumZipEntries, readEntry } from './zip-entries';
3434
import {
3535
fetchZipEntryData,
36+
fetchZipEntryHead,
3637
openRemoteZip,
3738
RangeUnsupportedError,
3839
readRemoteZipEntry,
@@ -56,6 +57,19 @@ export class BundleHashMismatchError extends PermanentBaseError {
5657
}
5758
}
5859

60+
/** The base bundle is not Hermes bytecode of the version being compiled. */
61+
export class HbcVersionMismatchError extends PermanentBaseError {
62+
constructor(
63+
readonly version: number | null,
64+
readonly expected: number,
65+
) {
66+
super(`downloaded base is HBC ${version ?? 'n/a'}, need ${expected}`);
67+
}
68+
}
69+
70+
/** bytes `getHbcVersion` needs to read the version */
71+
const HBC_HEAD_BYTES = 128;
72+
5973
/** time allowed for a response to start (headers) */
6074
const FETCH_HEADERS_TIMEOUT_MS = 30_000;
6175
/** a download that receives nothing for this long is abandoned */
@@ -613,6 +627,9 @@ interface FetchedBaseBundle {
613627
totalBytes?: number;
614628
}
615629

630+
/** the head at a recorded bundle location is not the expected bytecode */
631+
class StaleHeadError extends Error {}
632+
616633
function hasBundleLocation(record: HermesBaseServerRecord): boolean {
617634
return (
618635
record.bundleOffset != null &&
@@ -674,12 +691,19 @@ async function saveResponse(
674691
* recorded location is stale, so the directory transport (2) still runs; a
675692
* mismatch of the entry itself (2, 3) is final — the same bytes would come
676693
* back however they are fetched — and raises BundleHashMismatchError.
694+
*
695+
* With `expectHbcVersion` (the server does not know the base's version), the
696+
* bundle's first bytes are checked before its body is fetched; a base of
697+
* another HBC version, or plain JS, raises HbcVersionMismatchError without
698+
* downloading the rest. A head read at a recorded location (1) may be stale,
699+
* so only the directory transport (2) decides.
677700
*/
678701
export async function fetchBaseBundle(
679702
record: HermesBaseServerRecord,
680703
artifactType: BaseArtifactType,
681704
archive: string,
682705
log: (message: string) => void = () => {},
706+
expectHbcVersion?: number,
683707
): Promise<FetchedBaseBundle> {
684708
const matches = bundleEntryMatcher(artifactType);
685709
const verified = (
@@ -707,14 +731,21 @@ export async function fetchBaseBundle(
707731
// harmony .app nests the bundle in a second zip, so a location inside the
708732
// outer archive is never reported for it
709733
if (artifactType !== 'app' && hasBundleLocation(record)) {
734+
const location = {
735+
dataOffset: record.bundleOffset as number,
736+
compressedSize: record.bundleCompressedSize as number,
737+
compressionMethod: record.bundleCompression as number,
738+
};
710739
try {
740+
const headMatches =
741+
expectHbcVersion === undefined ||
742+
getHbcVersion(
743+
await fetchZipEntryHead(record.url, location, HBC_HEAD_BYTES),
744+
) === expectHbcVersion;
745+
if (!headMatches) throw new StaleHeadError();
711746
const { data, fetchedBytes, totalBytes } = await fetchZipEntryData(
712747
record.url,
713-
{
714-
dataOffset: record.bundleOffset as number,
715-
compressedSize: record.bundleCompressedSize as number,
716-
compressionMethod: record.bundleCompression as number,
717-
},
748+
location,
718749
);
719750
return {
720751
...verified(data),
@@ -727,7 +758,10 @@ export async function fetchBaseBundle(
727758
skip('server ignores Range');
728759
return fromFullResponse(error.response);
729760
}
730-
skip(`entry range: ${error?.message ?? error}`);
761+
// not a transport failure: the directory read below settles it
762+
if (!(error instanceof StaleHeadError)) {
763+
skip(`entry range: ${error?.message ?? error}`);
764+
}
731765
}
732766
}
733767

@@ -742,6 +776,17 @@ export async function fetchBaseBundle(
742776
remote.zipFile,
743777
matches,
744778
remote.reader,
779+
expectHbcVersion === undefined
780+
? undefined
781+
: {
782+
bytes: HBC_HEAD_BYTES,
783+
check: (head) => {
784+
const version = getHbcVersion(head);
785+
if (version !== expectHbcVersion) {
786+
throw new HbcVersionMismatchError(version, expectHbcVersion);
787+
}
788+
},
789+
},
745790
);
746791
return {
747792
...verified(bundle),
@@ -916,7 +961,14 @@ export async function resolveHermesBase(
916961
);
917962
try {
918963
log(t('hermesBaseDownloading', { url: record.url }));
919-
const fetched = await fetchBaseBundle(record, artifactType, archive, log);
964+
const fetched = await fetchBaseBundle(
965+
record,
966+
artifactType,
967+
archive,
968+
log,
969+
// a server that knows the version already matched it above
970+
record.bytecodeVersion == null ? bytecodeVersion : undefined,
971+
);
920972
const { bundle, bundleHash: actualHash } = fetched;
921973
if (fetched.transport !== 'full') {
922974
log(
@@ -928,12 +980,7 @@ export async function resolveHermesBase(
928980
}
929981
const version = getHbcVersion(bundle);
930982
if (version !== bytecodeVersion) {
931-
log(
932-
t('hermesBaseNone', {
933-
reason: `downloaded base is HBC ${version ?? 'n/a'}, need ${bytecodeVersion}`,
934-
}),
935-
);
936-
return null;
983+
throw new HbcVersionMismatchError(version, bytecodeVersion);
937984
}
938985
const cached = await cachePut(bundle, params.cacheMaxMb, actualHash);
939986
log(
@@ -954,6 +1001,10 @@ export async function resolveHermesBase(
9541001
source,
9551002
};
9561003
} catch (error: any) {
1004+
if (error instanceof HbcVersionMismatchError) {
1005+
log(t('hermesBaseNone', { reason: error.message }));
1006+
return null;
1007+
}
9571008
if (attempt === 2 || error instanceof PermanentBaseError) {
9581009
log(
9591010
t('hermesBaseNone', {

‎src/utils/zip-range.ts‎

Lines changed: 134 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import {
1515
RandomAccessReader,
1616
type ZipFile,
1717
} from 'yauzl';
18-
import { inflateRawSync } from 'zlib';
18+
import { inflateRawSync, constants as zlibConstants } from 'zlib';
1919
import { webFetch } from './runtime';
2020
import { readEntry } from './zip-entries';
2121

@@ -33,6 +33,11 @@ const TAIL_BYTES = 22 + 0xffff + 20;
3333
const CHUNK_BYTES = 64 * 1024;
3434
/** extra bytes past a hinted entry, covering a longer local extra field */
3535
const HINT_SLACK_BYTES = 4 * 1024;
36+
/**
37+
* compressed bytes fetched to decode the head of a deflated entry: the
38+
* dynamic Huffman header plus the first literals fit comfortably
39+
*/
40+
const HEAD_PROBE_BYTES = 4 * 1024;
3641

3742
export interface ZipEntryLocation {
3843
fileName: string;
@@ -199,10 +204,11 @@ function timeoutFor(bytes: number, options: RangeOptions): number {
199204
if (bytes <= CHUNK_BYTES) {
200205
return options.headerTimeoutMs ?? DEFAULT_HEADER_TIMEOUT_MS;
201206
}
202-
return (
207+
// AbortSignal.timeout() rejects a fractional delay
208+
return Math.ceil(
203209
(options.dataTimeoutMs ?? DEFAULT_DATA_TIMEOUT_MS) +
204-
((options.dataTimeoutPerMbMs ?? DEFAULT_DATA_TIMEOUT_PER_MB_MS) * bytes) /
205-
(1024 * 1024)
210+
((options.dataTimeoutPerMbMs ?? DEFAULT_DATA_TIMEOUT_PER_MB_MS) * bytes) /
211+
(1024 * 1024),
206212
);
207213
}
208214

@@ -360,6 +366,65 @@ export async function fetchZipEntryData(
360366
};
361367
}
362368

369+
/**
370+
* Up to `bytes` leading bytes of an entry, decoded from a prefix of its
371+
* compressed data (a truncated deflate stream is inflated as far as it goes).
372+
*/
373+
export function decodeEntryHead(
374+
raw: Buffer,
375+
compressionMethod: number,
376+
bytes: number,
377+
): Buffer {
378+
if (compressionMethod === ZIP_STORED) return raw.subarray(0, bytes);
379+
if (compressionMethod !== ZIP_DEFLATED) {
380+
throw new Error(`unsupported compression method ${compressionMethod}`);
381+
}
382+
return inflateRawSync(raw, {
383+
finishFlush: zlibConstants.Z_SYNC_FLUSH,
384+
}).subarray(0, bytes);
385+
}
386+
387+
/** compressed bytes needed to decode the first `bytes` of an entry */
388+
function headProbeSize(
389+
compressedSize: number,
390+
compressionMethod: number,
391+
bytes: number,
392+
): number {
393+
return Math.min(
394+
compressedSize,
395+
compressionMethod === ZIP_STORED ? bytes : HEAD_PROBE_BYTES,
396+
);
397+
}
398+
399+
/**
400+
* The first `bytes` of an entry whose compressed bytes are already located,
401+
* from one small Range request (see `fetchZipEntryData`).
402+
*/
403+
export async function fetchZipEntryHead(
404+
url: string,
405+
location: Pick<
406+
ZipEntryLocation,
407+
'dataOffset' | 'compressedSize' | 'compressionMethod'
408+
>,
409+
bytes: number,
410+
options: RangeOptions = {},
411+
): Promise<Buffer> {
412+
const { dataOffset, compressedSize, compressionMethod } = location;
413+
if (
414+
!Number.isInteger(dataOffset) ||
415+
!Number.isInteger(compressedSize) ||
416+
dataOffset < 0 ||
417+
compressedSize <= 0
418+
) {
419+
throw new Error('invalid bundle location');
420+
}
421+
const size = headProbeSize(compressedSize, compressionMethod, bytes);
422+
const { data } = await fetchRangeBuffer(url, dataOffset, dataOffset + size, {
423+
timeoutMs: timeoutFor(size, options),
424+
});
425+
return decodeEntryHead(data, compressionMethod, bytes);
426+
}
427+
363428
export interface HttpRangeReaderOptions extends RangeOptions {
364429
/** validator of the archive (see `rangeValidator`), sent as If-Range */
365430
ifRange?: string;
@@ -693,14 +758,67 @@ export async function openRemoteZip(
693758
return { kind: 'zip', zipFile, reader };
694759
}
695760

761+
/** `reader.read` as a promise */
762+
function readAt(
763+
reader: HttpRangeReader,
764+
position: number,
765+
length: number,
766+
): Promise<Buffer> {
767+
const buffer = Buffer.alloc(length);
768+
return new Promise((resolve, reject) =>
769+
reader.read(buffer, 0, length, position, (error) =>
770+
error ? reject(error) : resolve(buffer),
771+
),
772+
);
773+
}
774+
775+
/** The first `bytes` of `entry`, read through `reader` without its body. */
776+
async function remoteEntryHead(
777+
reader: HttpRangeReader,
778+
entry: Entry,
779+
bytes: number,
780+
): Promise<Buffer> {
781+
const start = entry.relativeOffsetOfLocalHeader;
782+
const header = await readAt(reader, start, LOCAL_HEADER_SIZE);
783+
if (header.readUInt32LE(0) !== LOCAL_HEADER_SIGNATURE) {
784+
throw new Error('invalid local file header signature');
785+
}
786+
const dataOffset =
787+
start +
788+
LOCAL_HEADER_SIZE +
789+
header.readUInt16LE(26) +
790+
header.readUInt16LE(28);
791+
const size = headProbeSize(
792+
entry.compressedSize,
793+
entry.compressionMethod,
794+
bytes,
795+
);
796+
if (size <= 0) return Buffer.alloc(0);
797+
return decodeEntryHead(
798+
await readAt(reader, dataOffset, size),
799+
entry.compressionMethod,
800+
bytes,
801+
);
802+
}
803+
804+
/** Look at the head of the matched entry before its body is fetched. */
805+
export interface EntryHeadCheck {
806+
bytes: number;
807+
/** throw to abandon the entry; the error is passed on as is */
808+
check: (head: Buffer) => void;
809+
}
810+
696811
/**
697812
* Read the first matching entry of an opened remote zip, then close it. With
698-
* `reader` given, the entry's header and data are fetched in one request.
813+
* `reader` given, the entry's header and data are fetched in one request —
814+
* or, with `head` too, its first bytes are checked first (header and head
815+
* share one small request, the rest of the body follows only if they pass).
699816
*/
700817
export function readRemoteZipEntry(
701818
zipFile: ZipFile,
702819
matches: (name: string) => boolean,
703820
reader?: HttpRangeReader,
821+
head?: EntryHeadCheck,
704822
): Promise<Buffer | null> {
705823
return new Promise((resolve, reject) => {
706824
let settled = false;
@@ -717,11 +835,17 @@ export function readRemoteZipEntry(
717835
zipFile.readEntry();
718836
return;
719837
}
720-
reader?.hintEntry(entry);
721-
readEntry(entry, zipFile).then(
722-
(data) => finish(null, data),
723-
(error) => finish(error),
724-
);
838+
const probe =
839+
reader && head
840+
? remoteEntryHead(reader, entry, head.bytes).then(head.check)
841+
: // without a probe the header read may take the body along
842+
Promise.resolve(reader?.hintEntry(entry));
843+
probe
844+
.then(() => readEntry(entry, zipFile))
845+
.then(
846+
(data) => finish(null, data),
847+
(error) => finish(error),
848+
);
725849
});
726850
zipFile.readEntry();
727851
});

0 commit comments

Comments
 (0)