[GH-ISSUE #8945] [Bug] inspect-native-dispatch-metadata drops class and metaclass rows when an adopted-protocol list exceeds its count limit #7793

Open
opened 2026-11-05 01:35:36 +00:00 by bsakaguchi · 1 comment
Owner

Originally created by @coygeek on GitHub (Nov 5, 2026).
Original GitHub issue: https://github.com/morluto/rea/issues/8945

Before submitting

  • I searched the open and closed issues for a related report.
  • I removed credentials, tokens, private paths, and sensitive target data.

Area

Native analysis or decompilation

Observed behavior

rea inspect-native-dispatch-metadata <binary> --format json returns no objc_classes rows when a class's adopted-protocol list declares a count of 20001. The class and its metaclass are independently readable: the otherwise identical count-1 control returns both rows with complete class coverage. The oversized input returns exit 0 and partial coverage with Protocol list exceeds 20000 entries, but loses the two rows and their embedded properties and class metadata.

The method implementation and ivar records survive, including the implementation's class_name: "Fixture". This is partial metadata loss, not failure of the whole CLI operation. The count limit itself need not be removed.

Reproduction

Use a built checkout at revision c3647ade1e557c4179a8b825a37e0c2193cd2526, with its locked dependencies and Node.js 24.22.1 available. The baseline supplied for this verification was already built; rebuilding from a fresh dependency installation was not part of the observed checks.

From the checkout root, run the shell block under Relevant output or evidence. It constructs four small synthetic Mach-O files in a disposable directory and calls the unmodified real CLI on each, with isolated HOME and XDG paths and no inherited client/provider configuration. It reads the existing fixture declaration only to construct bytes; inspection always goes through the CLI.

The controls are a protocol count of zero, a count of one, and a count of one whose protocol declaration has a truncated declared size. The failing variation changes only the count from one to 20001. All four commands exited 0. The final fixture matrix was executed once against the source baseline and once against the separately installed published package. The included portable shell reproduction was then executed against the baseline and reproduced the same matrix.

Expected behavior

Retain independently readable class and metaclass facts while reporting the adopted-protocol-list limitation. The class's protocol list must not be presented as fully decoded, and the existing count bound should continue to prevent oversized traversal. Unavailable protocol data must have an explicit failure or partial-coverage reason.

Expected source: AGENTS.md at the tested revision requires preserving partial facts and meaningful failure reasons. The native inspection documentation advertises class/metaclass decoding. Property-list recovery retains the independent owner after a sublist failure; the malformed protocol-declaration control below also retains both class rows. These support retaining independent facts without accepting invalid protocol data. A particular new class-level reason code is not asserted as an existing API guarantee.

Impact and scope

P2: bounded loss of useful static metadata for a malformed class-owned protocol-list count. The observed input loses class rows, properties embedded in those rows, and class/metaclass metadata while retaining method implementation and ivar records. No production-binary prevalence or broader downstream effects were measured.

No practical workaround preserving the original artifact identity and these class rows was demonstrated. Editing the count changes the evidence. The operation already reports partial coverage, so callers can detect a limitation; the defect is the unnecessary loss of independent facts.

REA version

6.8.0, source revision c3647ade1e557c4179a8b825a37e0c2193cd2526; reproduced separately with published rea-agents@6.8.0. npm view rea-agents version returned 6.8.0 during verification. Both CLI runners reported 6.8.0.

Runtime and platform

Node.js v24.22.1; macOS 27.2, build 26B123; arm64. The static Apple metadata reader handles these inputs without an external decompiler.

Target and artifact context

4096-byte synthetic, 64-bit little-endian arm64 Mach-O files containing a root class and metaclass, one instance method, one ivar, three class properties, and one adopted protocol in the valid control. Root flags and the executable header are set consistently across the fixtures. No target code is executed.

Interface

CLI

Earliest failure stage

Native analysis or reconstruction

Relevant output or evidence

Executed shell reproduction, from the built checkout root with Node.js 24.22.1 first on PATH:

set -eu
rea_entry="$PWD/scripts/rea.mjs"
work=$(mktemp -d)
mkdir "$work/home"
node --input-type=module - "$work" <<'JS'
import { readFileSync, writeFileSync } from 'node:fs';
import { stripTypeScriptTypes } from 'node:module';
import { join } from 'node:path';
const source = readFileSync('src/native/AppleDispatchMetadata.test.ts', 'utf8');
const declaration = source.slice(source.indexOf('const base ='), source.indexOf('const provenance ='));
const fixture = new Function(stripTypeScriptTypes(declaration) + '\nreturn fixture;')();
for (const [label, count] of [['zero', 0n], ['control', 1n], ['oversized', 20001n], ['bad-protocol-record', 1n]]) {
  const b = fixture();
  b.writeUInt32LE(2, 12);
  b.writeUInt32LE(5, 88);
  b.writeUInt32LE(2, 0x300);
  b.writeUInt32LE(3, 0x380);
  b.writeBigUInt64LE(count, 0x700);
  if (label === 'bad-protocol-record') b.writeUInt32LE(64, 0x780);
  writeFileSync(join(process.argv[2], label + '.macho'), b);
}
JS
for label in zero control oversized bad-protocol-record; do
  env -i PATH="$PATH" HOME="$work/home" XDG_CONFIG_HOME="$work/home/.config" XDG_CACHE_HOME="$work/home/.cache" \
    node "$rea_entry" inspect-native-dispatch-metadata "$work/$label.macho" --format json > "$work/$label.json"
  node --input-type=module - "$work/$label.json" "$label" <<'JS'
import { readFileSync } from 'node:fs';
const r = JSON.parse(readFileSync(process.argv[2], 'utf8')).normalized_result.result;
console.log(process.argv[3], JSON.stringify({classes:r.objc_classes.map(x=>({name:x.name,meta:x.is_meta_class})),protocols:r.objc_protocols.map(x=>x.name),ivars:r.objc_ivars.length,implementations:r.objc_dispatch_implementations.map(x=>({class_name:x.class_name,selector:x.selector})),coverage:r.coverage.find(x=>x.facet==='objc_class_method_ivar_metadata')}));
JS
done

Observed selected CLI fields, identical on the source baseline and the published package:

zero {"classes":[{"name":"Fixture","meta":false},{"name":"Fixture","meta":true}],"protocols":[],"ivars":1,"implementations":[{"class_name":"Fixture","selector":"performAction:"}],"coverage":{"facet":"objc_class_method_ivar_metadata","status":"complete","reason":null,"examined":2,"decoded":2}}
control {"classes":[{"name":"Fixture","meta":false},{"name":"Fixture","meta":true}],"protocols":["FixtureProtocol"],"ivars":1,"implementations":[{"class_name":"Fixture","selector":"performAction:"}],"coverage":{"facet":"objc_class_method_ivar_metadata","status":"complete","reason":null,"examined":2,"decoded":2}}
oversized {"classes":[],"protocols":[],"ivars":1,"implementations":[{"class_name":"Fixture","selector":"performAction:"}],"coverage":{"facet":"objc_class_method_ivar_metadata","status":"partial","reason":"0x100000180: Protocol list exceeds 20000 entries","examined":1,"decoded":0}}
bad-protocol-record {"classes":[{"name":"Fixture","meta":false},{"name":"Fixture","meta":true}],"protocols":[],"ivars":1,"implementations":[{"class_name":"Fixture","selector":"performAction:"}],"coverage":{"facet":"objc_class_method_ivar_metadata","status":"partial","reason":"Protocol 0x100000740: Truncated Objective-C protocol declaration","examined":2,"decoded":2}}

The unmodified focused parser suite also passed: npm run test:focused -- src/native/AppleDispatchMetadata.test.ts, one file and 108 tests. That pass does not assert coverage of this count-guard case. The fixture-construction API prints Node's experimental TypeScript-stripping notice; each REA call itself succeeds with empty stderr.

Typed outcome or recovery expected

The same oversized-count file should retain the independently decodable class and metaclass rows, and expose the protocol-list failure through truthful partial status or coverage. The zero-count and count-1 controls should retain complete class coverage; the truncated-declaration control should continue retaining both rows with its existing protocol failure. Retain method implementation and ivar facts and the original target digest. The original file must remain unchanged.

Analysis or suspected cause

Source-derived: AppleObjcProtocols.ts:22-27 throws when the list count exceeds 20000, before record-level recovery can run. AppleObjcDispatchFacets.ts:364 calls this list reader before appending the class at line 371 and before visiting its metaclass at line 401. The outer class-list catch records the reason but does not append either row. Ivars and implementation records have already been appended, explaining their survival.

The current origin/main fetched for comparison was 71f680d7c1aa7a45ba0325fe87b104744b0c105f. The protocol reader, Objective-C dispatch facets, top-level Apple metadata reader and dispatch coverage files have no diff from the tested baseline. This is a source comparison, not a runtime test of that main revision.

Additional context

Open and closed issue/PR searches covered protocol-list aliases, count values, discarded-class wording, title-only protocol results, oversized inputs and the exact failure text. No current open exact duplicate was found; the exact failure text returned no issues or PRs.

Related closed reports concern different failure boundaries: #2610, an unmapped top-level protocol-list slot; #3873, an empty protocol declaration name fixed by merged #4078; #4115, an empty category name; and #3336, malformed property records. Their fixes do not cover the nested adopted-protocol-list count guard reproduced here.

The closed finer-grained ivar-recovery proposal #4543 is relevant context for recovery granularity, but it concerns different records and its closure explicitly does not establish that the residual loss was fixed or rejected. No universal guarantee to recover every unsupported list layout is claimed here.

Originally created by @coygeek on GitHub (Nov 5, 2026). Original GitHub issue: https://github.com/morluto/rea/issues/8945 ### Before submitting - [x] I searched the open and closed issues for a related report. - [x] I removed credentials, tokens, private paths, and sensitive target data. ### Area Native analysis or decompilation ### Observed behavior `rea inspect-native-dispatch-metadata <binary> --format json` returns no `objc_classes` rows when a class's adopted-protocol list declares a count of 20001. The class and its metaclass are independently readable: the otherwise identical count-1 control returns both rows with complete class coverage. The oversized input returns exit 0 and partial coverage with `Protocol list exceeds 20000 entries`, but loses the two rows and their embedded properties and class metadata. The method implementation and ivar records survive, including the implementation's `class_name: "Fixture"`. This is partial metadata loss, not failure of the whole CLI operation. The count limit itself need not be removed. ### Reproduction Use a built checkout at revision `c3647ade1e557c4179a8b825a37e0c2193cd2526`, with its locked dependencies and Node.js 24.22.1 available. The baseline supplied for this verification was already built; rebuilding from a fresh dependency installation was not part of the observed checks. From the checkout root, run the shell block under Relevant output or evidence. It constructs four small synthetic Mach-O files in a disposable directory and calls the unmodified real CLI on each, with isolated HOME and XDG paths and no inherited client/provider configuration. It reads the existing fixture declaration only to construct bytes; inspection always goes through the CLI. The controls are a protocol count of zero, a count of one, and a count of one whose protocol declaration has a truncated declared size. The failing variation changes only the count from one to 20001. All four commands exited 0. The final fixture matrix was executed once against the source baseline and once against the separately installed published package. The included portable shell reproduction was then executed against the baseline and reproduced the same matrix. ### Expected behavior Retain independently readable class and metaclass facts while reporting the adopted-protocol-list limitation. The class's protocol list must not be presented as fully decoded, and the existing count bound should continue to prevent oversized traversal. Unavailable protocol data must have an explicit failure or partial-coverage reason. Expected source: [AGENTS.md at the tested revision](https://github.com/morluto/rea/blob/c3647ade1e557c4179a8b825a37e0c2193cd2526/AGENTS.md#L37) requires preserving partial facts and meaningful failure reasons. [The native inspection documentation](https://github.com/morluto/rea/blob/c3647ade1e557c4179a8b825a37e0c2193cd2526/docs/native-investigation.md#L848) advertises class/metaclass decoding. [Property-list recovery](https://github.com/morluto/rea/blob/c3647ade1e557c4179a8b825a37e0c2193cd2526/src/native/AppleObjcProperties.ts#L157) retains the independent owner after a sublist failure; the malformed protocol-declaration control below also retains both class rows. These support retaining independent facts without accepting invalid protocol data. A particular new class-level reason code is not asserted as an existing API guarantee. ### Impact and scope P2: bounded loss of useful static metadata for a malformed class-owned protocol-list count. The observed input loses class rows, properties embedded in those rows, and class/metaclass metadata while retaining method implementation and ivar records. No production-binary prevalence or broader downstream effects were measured. No practical workaround preserving the original artifact identity and these class rows was demonstrated. Editing the count changes the evidence. The operation already reports partial coverage, so callers can detect a limitation; the defect is the unnecessary loss of independent facts. ### REA version `6.8.0`, source revision `c3647ade1e557c4179a8b825a37e0c2193cd2526`; reproduced separately with published `rea-agents@6.8.0`. `npm view rea-agents version` returned `6.8.0` during verification. Both CLI runners reported `6.8.0`. ### Runtime and platform Node.js v24.22.1; macOS 27.2, build 26B123; arm64. The static Apple metadata reader handles these inputs without an external decompiler. ### Target and artifact context 4096-byte synthetic, 64-bit little-endian arm64 Mach-O files containing a root class and metaclass, one instance method, one ivar, three class properties, and one adopted protocol in the valid control. Root flags and the executable header are set consistently across the fixtures. No target code is executed. ### Interface CLI ### Earliest failure stage Native analysis or reconstruction ### Relevant output or evidence Executed shell reproduction, from the built checkout root with Node.js 24.22.1 first on PATH: ```sh set -eu rea_entry="$PWD/scripts/rea.mjs" work=$(mktemp -d) mkdir "$work/home" node --input-type=module - "$work" <<'JS' import { readFileSync, writeFileSync } from 'node:fs'; import { stripTypeScriptTypes } from 'node:module'; import { join } from 'node:path'; const source = readFileSync('src/native/AppleDispatchMetadata.test.ts', 'utf8'); const declaration = source.slice(source.indexOf('const base ='), source.indexOf('const provenance =')); const fixture = new Function(stripTypeScriptTypes(declaration) + '\nreturn fixture;')(); for (const [label, count] of [['zero', 0n], ['control', 1n], ['oversized', 20001n], ['bad-protocol-record', 1n]]) { const b = fixture(); b.writeUInt32LE(2, 12); b.writeUInt32LE(5, 88); b.writeUInt32LE(2, 0x300); b.writeUInt32LE(3, 0x380); b.writeBigUInt64LE(count, 0x700); if (label === 'bad-protocol-record') b.writeUInt32LE(64, 0x780); writeFileSync(join(process.argv[2], label + '.macho'), b); } JS for label in zero control oversized bad-protocol-record; do env -i PATH="$PATH" HOME="$work/home" XDG_CONFIG_HOME="$work/home/.config" XDG_CACHE_HOME="$work/home/.cache" \ node "$rea_entry" inspect-native-dispatch-metadata "$work/$label.macho" --format json > "$work/$label.json" node --input-type=module - "$work/$label.json" "$label" <<'JS' import { readFileSync } from 'node:fs'; const r = JSON.parse(readFileSync(process.argv[2], 'utf8')).normalized_result.result; console.log(process.argv[3], JSON.stringify({classes:r.objc_classes.map(x=>({name:x.name,meta:x.is_meta_class})),protocols:r.objc_protocols.map(x=>x.name),ivars:r.objc_ivars.length,implementations:r.objc_dispatch_implementations.map(x=>({class_name:x.class_name,selector:x.selector})),coverage:r.coverage.find(x=>x.facet==='objc_class_method_ivar_metadata')})); JS done ``` Observed selected CLI fields, identical on the source baseline and the published package: ```text zero {"classes":[{"name":"Fixture","meta":false},{"name":"Fixture","meta":true}],"protocols":[],"ivars":1,"implementations":[{"class_name":"Fixture","selector":"performAction:"}],"coverage":{"facet":"objc_class_method_ivar_metadata","status":"complete","reason":null,"examined":2,"decoded":2}} control {"classes":[{"name":"Fixture","meta":false},{"name":"Fixture","meta":true}],"protocols":["FixtureProtocol"],"ivars":1,"implementations":[{"class_name":"Fixture","selector":"performAction:"}],"coverage":{"facet":"objc_class_method_ivar_metadata","status":"complete","reason":null,"examined":2,"decoded":2}} oversized {"classes":[],"protocols":[],"ivars":1,"implementations":[{"class_name":"Fixture","selector":"performAction:"}],"coverage":{"facet":"objc_class_method_ivar_metadata","status":"partial","reason":"0x100000180: Protocol list exceeds 20000 entries","examined":1,"decoded":0}} bad-protocol-record {"classes":[{"name":"Fixture","meta":false},{"name":"Fixture","meta":true}],"protocols":[],"ivars":1,"implementations":[{"class_name":"Fixture","selector":"performAction:"}],"coverage":{"facet":"objc_class_method_ivar_metadata","status":"partial","reason":"Protocol 0x100000740: Truncated Objective-C protocol declaration","examined":2,"decoded":2}} ``` The unmodified focused parser suite also passed: `npm run test:focused -- src/native/AppleDispatchMetadata.test.ts`, one file and 108 tests. That pass does not assert coverage of this count-guard case. The fixture-construction API prints Node's experimental TypeScript-stripping notice; each REA call itself succeeds with empty stderr. ### Typed outcome or recovery expected The same oversized-count file should retain the independently decodable class and metaclass rows, and expose the protocol-list failure through truthful partial status or coverage. The zero-count and count-1 controls should retain complete class coverage; the truncated-declaration control should continue retaining both rows with its existing protocol failure. Retain method implementation and ivar facts and the original target digest. The original file must remain unchanged. ### Analysis or suspected cause Source-derived: [`AppleObjcProtocols.ts:22-27`](https://github.com/morluto/rea/blob/c3647ade1e557c4179a8b825a37e0c2193cd2526/src/native/AppleObjcProtocols.ts#L22) throws when the list count exceeds 20000, before record-level recovery can run. [`AppleObjcDispatchFacets.ts:364`](https://github.com/morluto/rea/blob/c3647ade1e557c4179a8b825a37e0c2193cd2526/src/native/AppleObjcDispatchFacets.ts#L364) calls this list reader before appending the class at line 371 and before visiting its metaclass at line 401. The outer class-list catch records the reason but does not append either row. Ivars and implementation records have already been appended, explaining their survival. The current `origin/main` fetched for comparison was `71f680d7c1aa7a45ba0325fe87b104744b0c105f`. The protocol reader, Objective-C dispatch facets, top-level Apple metadata reader and dispatch coverage files have no diff from the tested baseline. This is a source comparison, not a runtime test of that main revision. ### Additional context Open and closed issue/PR searches covered protocol-list aliases, count values, discarded-class wording, title-only protocol results, oversized inputs and the exact failure text. No current open exact duplicate was found; the exact failure text returned no issues or PRs. Related closed reports concern different failure boundaries: [#2610](https://github.com/morluto/rea/issues/2610), an unmapped top-level protocol-list slot; [#3873](https://github.com/morluto/rea/issues/3873), an empty protocol declaration name fixed by merged [#4078](https://github.com/morluto/rea/pull/4078); [#4115](https://github.com/morluto/rea/issues/4115), an empty category name; and [#3336](https://github.com/morluto/rea/issues/3336), malformed property records. Their fixes do not cover the nested adopted-protocol-list count guard reproduced here. The closed finer-grained ivar-recovery proposal [#4543](https://github.com/morluto/rea/pull/4543) is relevant context for recovery granularity, but it concerns different records and its closure explicitly does not establish that the residual loss was fixed or rejected. No universal guarantee to recover every unsupported list layout is claimed here.
Author
Owner

@xu91102 commented on GitHub (Nov 5, 2026):

Reproduced independently on fresh upstream main 71f680d7c1aa7a45ba0325fe87b104744b0c105f (Node 24.22.1, macOS arm64), using the real public inspect-native-dispatch-metadata CLI on four inert synthetic Mach-O inputs. All four commands exited 0 and all artifact hashes remained unchanged; no target code executed.

Adopted protocol-list input Class/metaclass rows Other retained facts
count 0 2 one ivar and one method implementation
count 1 2 protocol, three class properties, ivar and method
count 20001 0 ivar and method survive; coverage is partial
count 1, truncated protocol declaration 2 class properties, ivar and method survive with partial coverage

The oversized case reaches the shared adopted-protocol list count guard before the class row is published, so independently decoded class facts and the subsequent metaclass are lost. I will keep the 20,000-entry bound and qualify the unread list rather than relaxing the bound or claiming its contents are absent. The same shared list has class, category and adopting-protocol consumers; preservation tests will cover their independent owner facts and existing record-budget semantics.

I read the related production changes in #2639 (top-level list-slot recovery), #4078 (empty protocol name), #4549 (empty selectors) and #5573 (record-budget truncation). They concern different failure boundaries and do not handle this adopted-list count guard; no overlapping public PR was found in current issue/owner/protocol searches. I have not run those foreign heads. This is a bounded malformed-input partial-retention defect, with no claim about prevalence in production apps, arbitrary native execution, performance, or maintainer scope approval.

<!-- gh-comment-id:6648787674 --> @xu91102 commented on GitHub (Nov 5, 2026): Reproduced independently on fresh upstream main `71f680d7c1aa7a45ba0325fe87b104744b0c105f` (Node 24.22.1, macOS arm64), using the real public `inspect-native-dispatch-metadata` CLI on four inert synthetic Mach-O inputs. All four commands exited 0 and all artifact hashes remained unchanged; no target code executed. | Adopted protocol-list input | Class/metaclass rows | Other retained facts | | --- | --- | --- | | count 0 | 2 | one ivar and one method implementation | | count 1 | 2 | protocol, three class properties, ivar and method | | count 20001 | 0 | ivar and method survive; coverage is partial | | count 1, truncated protocol declaration | 2 | class properties, ivar and method survive with partial coverage | The oversized case reaches the shared adopted-protocol list count guard before the class row is published, so independently decoded class facts and the subsequent metaclass are lost. I will keep the 20,000-entry bound and qualify the unread list rather than relaxing the bound or claiming its contents are absent. The same shared list has class, category and adopting-protocol consumers; preservation tests will cover their independent owner facts and existing record-budget semantics. I read the related production changes in #2639 (top-level list-slot recovery), #4078 (empty protocol name), #4549 (empty selectors) and #5573 (record-budget truncation). They concern different failure boundaries and do not handle this adopted-list count guard; no overlapping public PR was found in current issue/owner/protocol searches. I have not run those foreign heads. This is a bounded malformed-input partial-retention defect, with no claim about prevalence in production apps, arbitrary native execution, performance, or maintainer scope approval.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
morluto/rea#7793
No description provided.