Refuse a PTE or PTD file whose schema version is newer than the runtime can read - #22114

Merged
shoumikhin merged 8 commits into
mainfrom
shoumikhin/program-version-gate
Aug 25, 2026
Merged

Refuse a PTE or PTD file whose schema version is newer than the runtime can read#22114
shoumikhin merged 8 commits into
mainfrom
shoumikhin/program-version-gate

Conversation

@shoumikhin

@shoumikhinshoumikhin commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

Summary

Every exported PTE file and every PTD data file carries a schema version. The exporters stamp it, and nothing in the runtime ever read it back. The only gate on a file was its four byte identifier, ET12 for a program and FT01 for data. That says "this is an ExecuTorch file" and says nothing about which shape of file it is. A file written by an exporter newer than the runtime was therefore accepted, then misread field by field, and failed later at load or during execution with an error that points somewhere else.

This matters more now that the two halves can come from different builds. The wheel ships prebuilt runtime libraries, so a user can export with one installation of ExecuTorch and run with a runtime that was never built next to it.

This PR reads the version in both readers, right after the root table is obtained, and refuses anything above the highest version the runtime supports. Program::load returns InvalidProgram, FlatTensorDataMap::load returns InvalidExternalData, which is what the checks around each of them return. Both name the two numbers:

Program schema version 1 is newer than the highest this runtime supports (0).
Export the model with an older ExecuTorch, or update the runtime.

The comparison is "less than or equal", not "equal", because the project promises that an older file keeps working on a newer runtime, and the schemas only ever grow by appending optional fields. Only a file from the future is refused.

Each new constant sits next to the class it guards, and points in a comment at the writer constants for the same number: EXECUTORCH_SCHEMA_VERSION for the program, kSchemaVersion and _FLAT_TENSOR_VERSION for the data file. A bump of one without the other is then easy to spot. Nothing writes a version other than zero today, so no file in the wild changes behavior. This is the reader side that a future version bump needs in order to mean anything.

One limit worth stating plainly: a runtime that is already deployed has no check at all, so this protects runtimes built from this change onward. That is inherent to adding a reader.

Test plan

Four new tests, two per format. Each pair builds a minimal file in memory through the real FlatBuffer builder, one stamped at the supported version and one above it. The first is expected to load and the second to be refused. The positive test is the control: without it, a rejection could just as well come from a malformed fixture as from the version. The program test asks for the cheapest verification level, which shows the check does not depend on full verification being enabled. The data test follows the byte layout that save_ptd() writes, with no segments.

Built and ran both suites on Linux x86_64, before and after the change:

cmake -S . -B cmake-out -DEXECUTORCH_BUILD_TESTS=ON \
-DEXECUTORCH_ENABLE_PROGRAM_VERIFICATION=ON \
-DEXECUTORCH_BUILD_EXTENSION_DATA_LOADER=ON \
-DEXECUTORCH_BUILD_EXTENSION_FLAT_TENSOR=ON
cmake --build cmake-out -j
./cmake-out/runtime/executor/test/program_test
./cmake-out/extension/flat_tensor/test/extension_flat_tensor_test

The existing tests pass unchanged in both runs, plus the four new ones. As a control, deleting either check while keeping the tests makes that format's negative test fail, with the newer file loading and reporting no error, so the check is what catches it.

Binary size, measured on the two object files. program.cpp.o grows by 24 bytes with logging disabled and 239 bytes with logging enabled. flat_tensor_data_map.cpp.o grows by 32 bytes with logging disabled and 243 bytes with logging enabled.

…read
Every exported PTE file carries a schema version. The exporter stamps it from
EXECUTORCH_SCHEMA_VERSION, and nothing in the runtime ever read it back. The only gate
on a file was the four byte identifier ET12, which says "this is an ExecuTorch program"
and says nothing about which shape of program it is. A file written by an exporter newer
than the runtime was therefore accepted, then misread field by field, and failed later
at load or during execution with an error that points somewhere else.
This matters more now that the two halves can come from different builds. The wheel
ships prebuilt runtime libraries, so a user can export with one installation of
ExecuTorch and run with a runtime that was never built next to it.
Read the version in Program::load, right after the root table is obtained, and refuse
anything above Program::kMaxSupportedSchemaVersion with InvalidProgram and a message
that names both numbers. The comparison is "less than or equal", not "equal", because
the project promises that an older file keeps working on a newer runtime, and the schema
only ever grows by appending optional fields. Only a file from the future is refused.
The new constant sits next to the class it guards, and the two constants point at each
other in comments so that a bump of one without the other is easy to spot. Nothing
writes a version other than zero today, so no file in the wild changes behavior. This is
the reader side that a future version bump needs in order to mean anything.
Two new tests build a minimal program through the real FlatBuffer builder, one stamped
at the supported version and one above it, and check that the first loads and the second
returns InvalidProgram at the cheapest verification level. Ran the program test suite on
Linux x86_64 before and after the change: the same tests pass in both, plus the two new
ones. Deleting the check and keeping the tests makes the negative test fail, with the
newer file loading successfully, so the check is what catches it. Measured on the object
file, the cost is 24 bytes with logging disabled and 239 bytes with logging enabled.
The companion PTD file has the same unread version field, and this change does not touch
it.
CopilotAI lite review requested due to automatic review settings August 24, 2026 23:14
@pytorch-bot

pytorch-botBot commented Aug 24, 2026

Copy link
Copy Markdown

🔗 Helpful Links

🧪 See artifacts and rendered test results at hud.pytorch.org/pr/pytorch/executorch/22114

Note: Links to docs will display an error until the docs builds have been completed.

❌ 1 New Failure, 2 Unclassified Failures

As of commit 8f613a8 with merge base 1afd07f (image):

NEW FAILURE - The following job has failed:

UNCLASSIFIED FAILURES - DrCI could not classify the following jobs because the workflow did not run on the merge base. The failures may be pre-existing on trunk or introduced by this PR:

This comment was automatically generated by Dr. CI and updates every 15 minutes.

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@meta-clameta-claBot added the CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. label Aug 24, 2026
@shoumikhinshoumikhin added the release notes: runtime Changes related to the core runtime which loads the program methods, initializes delegates, and runs label Aug 24, 2026
CopilotAI review requested due to automatic review settings August 24, 2026 23:19

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

…read
The data file has the same hole the program file had. Both writers, the C++ one in
serialize.h and the Python one in serialize.py, stamp a schema version into every PTD
file, and nothing in the runtime ever read it back. The four byte identifier FT01 was the
only gate, and it says nothing about which shape of data follows.
Read the version in FlatTensorDataMap::load, right after the root table is obtained, and
refuse anything above FlatTensorDataMap::kMaxSupportedSchemaVersion with
InvalidExternalData, which is what the checks around it return. The rule is the same as
for the program file: "less than or equal", so an older data file keeps loading.
Two new tests build a minimal PTD file in memory, one stamped at the supported version
and one above it. The positive one is the control. Without it, a rejection could just as
well come from a malformed fixture as from the version. Ran the data map suite on Linux
x86_64: the six existing tests pass unchanged and both new ones pass. Deleting the check
makes the negative test load the newer file with no error at all, so the check is what
catches it. Measured on the object file, the cost is 32 bytes with logging disabled and
243 bytes with logging enabled.
CopilotAI review requested due to automatic review settings August 24, 2026 23:30

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@shoumikhinshoumikhin changed the title Refuse a PTE file whose schema version is newer than the runtime can readRefuse a PTE or PTD file whose schema version is newer than the runtime can readAug 24, 2026
The comment next to each version check said the four byte identifier "only
says that this is a program" / "FlatTensor data". That undersells it. The
identifier is itself the coarse compatibility gate: ET12 and FT01 are bumped
only on a backward-incompatible schema change, so the identifier selects a
schema family and check_header already rejects a file from a different family
as IncompatibleVersion. The new version scalar is the finer gate within one
family. Reword both comments to say that, so a future reader does not conclude
the identifier carries no version meaning. Comment-only, no behavior change.
CopilotAI review requested due to automatic review settings August 25, 2026 00:29

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@GasoonjiaGasoonjia left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for doing the BC/FC check! I think there're two questions im not sure clear:

  1. When should we update the ExecuTorch version? What's the rule behind it?
  2. Is there any ci to mandatory make the version bump happen?

@Gasoonjia

Copy link
Copy Markdown
Contributor

@claude review the PR

@claude

claudeBot commented Aug 25, 2026

Copy link
Copy Markdown

Claude finished @Gasoonjia's task in 2m 7s —— View job


PR Review: Refuse a PTE/PTD file whose schema version is newer than the runtime can read

I read the full diff plus the surrounding load paths in program.cpp and flat_tensor_data_map.cpp. This is a clean, well-scoped change and the reasoning in the description is sound. Below is my feedback, followed by answers to @Gasoonjia's two questions.

What's good

  • Both checks sit in the right place — immediately after the root table pointer is obtained (program.cpp:234, flat_tensor_data_map.cpp:301) and return the same error the surrounding code already uses (InvalidProgram / InvalidExternalData).
  • <= rather than == is the correct choice given the append-only-optional-fields FC promise, and the error messages name both numbers and tell the user what to do.
  • Because version() defaults to 0 when the field is absent, files written before the field existed still load. And since nothing stamps a non-zero version today, this is inert in the wild — low risk.
  • The positive/negative test pairing is the right shape; the program negative test using Verification::Minimal nicely proves the gate doesn't depend on full verification.

The real gap — nothing enforces the constants stay in sync

This is the same concern behind @Gasoonjia's questions. The correctness of the gate depends on four hand-maintained constants agreeing:

ConstantFile
EXECUTORCH_SCHEMA_VERSIONexir/version.py
Program::kMaxSupportedSchemaVersionruntime/executor/program.h:85
_FLAT_TENSOR_VERSIONextension/flat_tensor/serialize/serialize.py:48
kSchemaVersionextension/flat_tensor/serialize/serialize.h
FlatTensorDataMap::kMaxSupportedSchemaVersionextension/flat_tensor/flat_tensor_data_map.h

Right now the only thing tying them together is the cross-reference comments. If someone bumps the writer (exir/version.py) and forgets the reader (program.h), the failure mode is exactly the silent misread this PR is trying to prevent — the reader would reject files it can and should read (an FC regression), and the comments alone won't catch it in review.

Consider adding a tiny guard so a bump can't drift:

  • A C++ static_assert(Program::kMaxSupportedSchemaVersion == kSchemaVersion ...) where the headers are both visible, and/or
  • A Python unit test that reads both .h files (or a generated constant) and asserts equality with EXECUTORCH_SCHEMA_VERSION / _FLAT_TENSOR_VERSION.

That would answer question 2 directly and make the comments enforceable rather than advisory.

Minor

  • Reading version() before full verification. In Verification::Minimal mode only root_offset is bounds-checked (program.cpp:216); flatbuffer_program->version() then dereferences the vtable of an otherwise-unverified buffer. This is consistent with the existing code (named_data(), constant_segment() are read the same way right after), so it's not a regression — just noting the version read inherits the same trust assumptions as everything below it. No change needed.
  • Comment claim about the file identifier. The comments state the 4-byte identifier "changes only on a backward-incompatible schema change." That's the intended contract, but I don't see anything in the schema or tooling that enforces the identifier bumps on a breaking change — it's a convention. Fine to keep, just be aware it's an assertion of intent, not a guarantee the code makes.
  • reinterpret_cast<const uint32_t*>(flatbuffer) in the flat_tensor test assumes little-endian + aligned start. Acceptable for a test fixture, and it mirrors save_ptd(), but it's the fragile part of the suite if the header layout ever changes.

Answers to @Gasoonjia

  1. When should the version bump? The schemas are append-only: adding an optional field is backward- and forward-compatible and does not require a bump (that's the whole point of the <= check). A bump is only meaningful when a change makes a new file unreadable-as-old by an old runtime — i.e. a semantic change to an existing field, or a new field the runtime must understand to execute correctly. Purely additive optional fields shouldn't bump; changes that old runtimes would silently misinterpret should. It would be worth stating this rule explicitly in a comment next to EXECUTORCH_SCHEMA_VERSION, since this PR is what gives the number teeth.
  2. Is there CI to mandate the bump? Not that I can find, and this PR doesn't add one. There's no check that a schema (.fbs) change forces a version bump, nor that the writer/reader constants match. The sync-assert / test suggested above covers the second half (constants agreeing); mandating a bump on .fbs changes is harder and probably out of scope here, but worth a follow-up issue.

Overall: correct and low-risk as written. The one thing I'd push for before merge is a mechanical guard that the writer and reader constants can't diverge — otherwise the whole mechanism is one forgotten edit away from silently regressing FC.
• branch shoumikhin/program-version-gate

CopilotAI review requested due to automatic review settings August 25, 2026 01:16

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

The version gate this PR adds is only correct while five hand-maintained
constants agree: the PTE writer (EXECUTORCH_SCHEMA_VERSION) and its runtime
reader (Program::kMaxSupportedSchemaVersion), and the two PTD writers
(_FLAT_TENSOR_VERSION, kSchemaVersion) and their reader
(FlatTensorDataMap::kMaxSupportedSchemaVersion). Until now only cross-reference
comments tied them together, so bumping a writer without its reader would make a
runtime refuse files it should read, and bumping one PTD writer without the
other would let a file be stamped below its real layout and misread.
Extend schema/test/test_schema.py, which already enforces cross-file schema
sync and runs in OSS CI with no build wiring, with a check that parses the five
literals as text and asserts the compatibility relationship: each writer must be
<= its reader ceiling (a reader may support a version before any writer emits
it, but never the reverse), and the two PTD writers must be exactly equal
because they stamp the same field of the same file. The parse keys on file path,
not symbol, since the two reader ceilings share a name, and fails closed if a
constant can no longer be found so a reformat can't silently disable the guard.
Also state the bump rule next to EXECUTORCH_SCHEMA_VERSION and soften the
file-identifier comments: the identifier is bumped by convention on a breaking
change, not by anything the code enforces.
CopilotAI review requested due to automatic review settings August 25, 2026 01:22

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@shoumikhin

Copy link
Copy Markdown
ContributorAuthor

Thanks @claude — good catch on the drift risk. I added the guard, and it's the right call to do it in this PR since this is what turns the constants into a real gate.

What I added: a check in schema/test/test_schema.py (the file that already enforces cross-file schema sync and runs in OSS CI, so no new build wiring). It reads all five version constants as text and asserts:

  • PTE: EXECUTORCH_SCHEMA_VERSIONProgram::kMaxSupportedSchemaVersion
  • PTD: _FLAT_TENSOR_VERSION == kSchemaVersionFlatTensorDataMap::kMaxSupportedSchemaVersion

The relationship is writer-≤-reader, not strict equality: a runtime is allowed to support a version before any writer emits it (forward-compat), but a writer must never stamp a version its reader would refuse. The two PTD writers are compared with == because they stamp the same field of the same file. The parse keys on the file path (the two reader ceilings share the name kMaxSupportedSchemaVersion) and fails if a constant can't be found, so a rename/reformat can't silently disable the check.

I verified it locally: passes today, fails when I bump one writer without its reader, fails when the two PTD writers disagree, and still passes when only a reader is bumped ahead (the legitimate case).

On the minors:

  • Softened both file-identifier comments to say the tag is bumped by convention on a breaking change — you're right it's not code-enforced.
  • Added the bump rule you described next to EXECUTORCH_SCHEMA_VERSION.
  • Left the version()-before-verification read and the test's reinterpret_cast as-is, since you flagged both as no-change-needed (the version read inherits the same trust as the reads around it; the cast mirrors save_ptd()).

On mandating a bump when a .fbs changes: still out of scope here (it needs schema-shape diffing), but the constant-sync half is now covered. Happy to file a follow-up for the .fbs-change mandate and for the Python PTD reader still using != (separate small PR).

@shoumikhin

Copy link
Copy Markdown
ContributorAuthor

@claude review the PR

@claude

claudeBot commented Aug 25, 2026

Copy link
Copy Markdown

Claude finished @shoumikhin's task in 1m 32s —— View job


Re: the drift guard (8f613a8)

I read the new test_schema.py::test_schema_version_constants_in_sync and traced all five regexes against the actual declarations. This resolves the concern from my earlier review — the guard is correct and lands in the right place.

Verified the five patterns match today's source:

ConstantDeclarationRegex
EXECUTORCH_SCHEMA_VERSION= 0 (exir/version.py:14)
Program::kMaxSupportedSchemaVersion= 0 (program.h:85)
_FLAT_TENSOR_VERSION: int = 0 (serialize.py:49)
kSchemaVersion= 0 (serialize.h:26)
FlatTensorDataMap::kMaxSupportedSchemaVersion= 0 (flat_tensor_data_map.h:47)

The design decisions are right:

  • Keying on file path, not symbol name is the correct call — the two reader ceilings both spell kMaxSupportedSchemaVersion, so a symbol-based parse would be ambiguous. Path-keyed re.search on each file avoids that, and since kMaxSupportedSchemaVersion doesn't contain the literal substring kSchemaVersion, the PTD-writer regex in serialize.h can't accidentally match a ceiling either.
  • Writer <= reader, PTD writers == matches the compatibility policy exactly: a runtime may support a version before any writer emits it (FC), but a writer must never stamp a version its reader refuses; and the two PTD writers stamp the same field, so they must be equal. The asymmetry is intentional and correctly encoded.
  • Fail-closed on a missing constant (assertIsNotNone with a message that tells the next editor to fix the test) is what makes a rename/reformat surface loudly instead of silently disabling the guard — this was the exact failure mode I was worried about.
  • Putting it in test_schema.py (already in OSS CI, no new build wiring) is the pragmatic choice over a static_assert, which couldn't reach the Python writers anyway.

On the minors — all handled as expected: the file-identifier comments now say "by convention," the bump rule is stated next to EXECUTORCH_SCHEMA_VERSION, and leaving the version()-before-verification read and the test reinterpret_cast as-is is fine.

One small note (non-blocking): the guard proves the five constants agree with each other, but the writer/reader ceiling and the actual serialized field are still tied only by the runtime code that reads version(). That's fine — that path is what your four new C++ tests exercise, so the two halves (constants-agree here, field-is-gated there) are each covered.

On follow-ups: filing separate issues for (a) mandating a version bump on .fbs shape changes and (b) the Python PTD reader still using != instead of <= sounds right — both are genuinely out of scope for this PR, and the constant-sync half was the load-bearing gap.

I couldn't run the suite here (sandbox blocked python), but the parse is simple text matching and I verified each pattern against its literal by hand; your local run (passes today, fails on each induced drift) covers the dynamic behavior.

LGTM.
• branch shoumikhin/program-version-gate

@claude

claudeBot commented Aug 25, 2026

Copy link
Copy Markdown

Claude finished @shoumikhin's task in 2m 4s —— View job


Re-review: Refuse a PTE/PTD file whose schema version is newer than the runtime can read

I re-read the full diff against origin/main, focused on the commit added since my last pass (the constant-sync guard) plus a fresh look at the readers and tests. My earlier concern — that the whole mechanism was one forgotten edit away from silently regressing FC — is now addressed. Summary: correct, low-risk, and I'd merge it.

The drift guard resolves the main gap

schema/test/test_schema.py::test_schema_version_constants_in_sync is the right fix in the right place — it lives in the file that already enforces cross-file schema sync and runs in OSS CI, so no new build wiring. Reviewing the logic:

  • Text-parse over import is the correct call here (importing the writers drags in torch; the C++ constants have no Python binding), and keying the parse on file path rather than symbol name correctly disambiguates the two reader ceilings that share kMaxSupportedSchemaVersion (program.h:80, flat_tensor_data_map.h:46).
  • The assertIsNotNone on every match means a rename or reformat fails the test loudly instead of silently disabling the check — exactly the failure mode you'd want to avoid in a guard like this.
  • The relations model the compatibility policy correctly: writer--reader for each family (a runtime may support a version before any writer emits it), and == between the two PTD writers (they stamp the same field of the same file). PTE and PTD kept independent.

Readers — verified placement

  • Program::load (program.cpp:226) — the check sits right after the root-table pointer and before named_data/constant_segment reads, returning InvalidProgram. Correct.
  • FlatTensorDataMap (flat_tensor_data_map.cpp:301) — same shape, returns InvalidExternalData. Correct.
  • <= (not ==), version defaulting to 0 when absent, and both numbers named in the message with an actionable fix — all as described.

Tests

  • The positive/negative pairing per format is the right shape, and the program negative test using Verification::Minimal proves the gate doesn't depend on full verification. The negative fixtures are otherwise-valid files differing only in the version field, so the rejection is attributable to the gate and nothing else.
  • The flat_tensor fixture's reinterpret_cast<const uint32_t*>(flatbuffer) assumes little-endian + aligned start — fine for a test fixture and it mirrors save_ptd(), as previously noted. No change needed.

Minors addressed

  • Both file-identifier comments now say the tag is bumped by convention — matches reality (the identifier isn't code-enforced to bump on a break).
  • The bump rule is now documented next to EXECUTORCH_SCHEMA_VERSION (exir/version.py), which is what gives the number teeth.

One optional follow-up (non-blocking)

As you noted, the guard covers the constants agreeing half. It does not mandate a version bump when a .fbs actually changes shape — that needs schema-shape diffing and is reasonably out of scope. Worth a follow-up issue, along with the Python PTD reader still comparing with != (serialize.py) rather than the <= semantics this PR establishes on the C++ side, so a future non-zero PTD version wouldn't regress the Python path.

Nothing here blocks merge. Nice work turning the cross-reference comments into an enforced check within this same PR.
• branch shoumikhin/program-version-gate

<cstring> and <vector> already arrive through buffer_data_loader.h and the
generated flatbuffers header, so drop them. Replace the per-symbol using
declarations with namespace directives, matching the convention used elsewhere.
CopilotAI review requested due to automatic review settings August 25, 2026 02:07

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@shoumikhin
shoumikhin merged commit d181029 into mainAug 25, 2026
212 of 213 checks passed
@shoumikhin
shoumikhin deleted the shoumikhin/program-version-gate branch August 25, 2026 02:44
shoumikhin added a commit that referenced this pull request Aug 25, 2026
…22117)
### Summary
The Python `FlatTensorSerializer.deserialize` refused any PTD file whose
`version` did not **exactly** equal `_FLAT_TENSOR_VERSION`:
```python
if flat_tensor.version != _FLAT_TENSOR_VERSION:
raise NotImplementedError(...)
```
That rejects an **older** file too, which contradicts:
- the append-only schema policy in `schema/README.md` (older files stay
loadable), and
- the C++ runtime readers, which accept anything `<=` their supported
version and only refuse a file **newer** than they understand
(`Program::load`, `FlatTensorDataMap::load`).
This aligns the Python PTD reader with that policy: compare with `>`
instead of `!=`, so an older or equal file loads and only a newer one is
refused. The error message now says the file is newer than this reader
supports.
### Context
This was called out as a follow-up during review of #22114 (which adds
the `<=` gate on the C++ side). It keeps the Python export/tooling path
consistent so a future non-zero PTD version won't regress it.
### Test plan
Two tests in `extension/flat_tensor/test/test_serialize.py`:
- `test_deserialize_refuses_newer_version` — a bumped-version file is
refused.
- `test_deserialize_accepts_older_version` — a file older than the
reader still loads (the case the old `!=` wrongly rejected).
Both are behavior-only; no schema or format change. Version constants
are unchanged (still `0`), so this is inert for existing files and only
changes behavior once a non-zero version is ever stamped.
shoumikhin added a commit that referenced this pull request Aug 25, 2026
### Summary
`FlatTensorDataMap::load()` reads the flatbuffer root table without
first bounds-checking the root offset:
```cpp
const flat_tensor_flatbuffer::FlatTensor* flat_tensor =
flat_tensor_flatbuffer::GetFlatTensor(flat_tensor_data->data());
// then immediately:
flat_tensor->named_data(); // walks the root vtable
flat_tensor->segments();
```
`GetFlatTensor()` interprets the `uoffset_t` at the start of the buffer
as the root-table offset and returns `buf + offset`. The first field
access then walks that table's vtable. Nothing on this path runs full
flatbuffer verification, so a corrupt or truncated PTD file with a bad
root offset makes these accessors dereference memory **outside the
buffer** (an OOB read, flagged by ASan).
The program loader already guards against exactly this in
`runtime/executor/program.cpp`. This change mirrors that guard for PTD
files.
### What changed
After the existing identifier and alignment checks, before
`GetFlatTensor()`:
- Confirm the buffer is at least a flatbuffer header long (`uoffset_t` +
file identifier).
- Confirm the root offset is `>= kMinBufferSize` and leaves room for a
vtable `soffset_t` within the buffer.
- Return `InvalidExternalData` otherwise (the same error the surrounding
checks use).
FlatTensor is **not** size-prefixed — both writers finish the buffer
plain (`builder.Finish` in C++, default `flatc` in Python) and the
reader uses `GetFlatTensor` (not the size-prefixed variant) — so the
root offset is at byte 0, exactly as in the program case.
### Scope
This is a pre-existing hardening, independent of the schema-version work
in #22114 (the version gate this protects is additive; the OOB path
exists today via `named_data()`/`segments()`).
### Test plan
New `FlatTensorDataMapTest.RejectsOutOfBoundsRootOffset`: copies a valid
PTD into a max-aligned buffer, overwrites only the root offset with a
value past the end (leaving identifier + alignment intact), and asserts
`load()` returns `InvalidExternalData`. Without the check, that same
input is an out-of-bounds vtable read under ASan.
> Note: I was unable to build/run the C++ suite in my local environment,
so I'm relying on this PR's CI (which builds the runtime and runs
`extension/flat_tensor` tests, including under ASan) as the
authoritative check. The change mirrors an existing, tested guard in
`program.cpp`.
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA SignedThis label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed.release notes: runtimeChanges related to the core runtime which loads the program methods, initializes delegates, and runs

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants

@shoumikhin@Gasoonjia@huydhn
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

Refuse a PTE or PTD file whose schema version is newer than the runtime can read - #22114

Merged
shoumikhin merged 8 commits into
mainfrom
shoumikhin/program-version-gate
Aug 25, 2026
Merged

Refuse a PTE or PTD file whose schema version is newer than the runtime can read#22114
shoumikhin merged 8 commits into
mainfrom
shoumikhin/program-version-gate

Conversation

@shoumikhin

@shoumikhinshoumikhin commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

Summary

Every exported PTE file and every PTD data file carries a schema version. The exporters stamp it, and nothing in the runtime ever read it back. The only gate on a file was its four byte identifier, ET12 for a program and FT01 for data. That says "this is an ExecuTorch file" and says nothing about which shape of file it is. A file written by an exporter newer than the runtime was therefore accepted, then misread field by field, and failed later at load or during execution with an error that points somewhere else.

This matters more now that the two halves can come from different builds. The wheel ships prebuilt runtime libraries, so a user can export with one installation of ExecuTorch and run with a runtime that was never built next to it.

This PR reads the version in both readers, right after the root table is obtained, and refuses anything above the highest version the runtime supports. Program::load returns InvalidProgram, FlatTensorDataMap::load returns InvalidExternalData, which is what the checks around each of them return. Both name the two numbers:

Program schema version 1 is newer than the highest this runtime supports (0).
Export the model with an older ExecuTorch, or update the runtime.

The comparison is "less than or equal", not "equal", because the project promises that an older file keeps working on a newer runtime, and the schemas only ever grow by appending optional fields. Only a file from the future is refused.

Each new constant sits next to the class it guards, and points in a comment at the writer constants for the same number: EXECUTORCH_SCHEMA_VERSION for the program, kSchemaVersion and _FLAT_TENSOR_VERSION for the data file. A bump of one without the other is then easy to spot. Nothing writes a version other than zero today, so no file in the wild changes behavior. This is the reader side that a future version bump needs in order to mean anything.

One limit worth stating plainly: a runtime that is already deployed has no check at all, so this protects runtimes built from this change onward. That is inherent to adding a reader.

Test plan

Four new tests, two per format. Each pair builds a minimal file in memory through the real FlatBuffer builder, one stamped at the supported version and one above it. The first is expected to load and the second to be refused. The positive test is the control: without it, a rejection could just as well come from a malformed fixture as from the version. The program test asks for the cheapest verification level, which shows the check does not depend on full verification being enabled. The data test follows the byte layout that save_ptd() writes, with no segments.

Built and ran both suites on Linux x86_64, before and after the change:

cmake -S . -B cmake-out -DEXECUTORCH_BUILD_TESTS=ON \
-DEXECUTORCH_ENABLE_PROGRAM_VERIFICATION=ON \
-DEXECUTORCH_BUILD_EXTENSION_DATA_LOADER=ON \
-DEXECUTORCH_BUILD_EXTENSION_FLAT_TENSOR=ON
cmake --build cmake-out -j
./cmake-out/runtime/executor/test/program_test
./cmake-out/extension/flat_tensor/test/extension_flat_tensor_test

The existing tests pass unchanged in both runs, plus the four new ones. As a control, deleting either check while keeping the tests makes that format's negative test fail, with the newer file loading and reporting no error, so the check is what catches it.

Binary size, measured on the two object files. program.cpp.o grows by 24 bytes with logging disabled and 239 bytes with logging enabled. flat_tensor_data_map.cpp.o grows by 32 bytes with logging disabled and 243 bytes with logging enabled.

…read
Every exported PTE file carries a schema version. The exporter stamps it from
EXECUTORCH_SCHEMA_VERSION, and nothing in the runtime ever read it back. The only gate
on a file was the four byte identifier ET12, which says "this is an ExecuTorch program"
and says nothing about which shape of program it is. A file written by an exporter newer
than the runtime was therefore accepted, then misread field by field, and failed later
at load or during execution with an error that points somewhere else.
This matters more now that the two halves can come from different builds. The wheel
ships prebuilt runtime libraries, so a user can export with one installation of
ExecuTorch and run with a runtime that was never built next to it.
Read the version in Program::load, right after the root table is obtained, and refuse
anything above Program::kMaxSupportedSchemaVersion with InvalidProgram and a message
that names both numbers. The comparison is "less than or equal", not "equal", because
the project promises that an older file keeps working on a newer runtime, and the schema
only ever grows by appending optional fields. Only a file from the future is refused.
The new constant sits next to the class it guards, and the two constants point at each
other in comments so that a bump of one without the other is easy to spot. Nothing
writes a version other than zero today, so no file in the wild changes behavior. This is
the reader side that a future version bump needs in order to mean anything.
Two new tests build a minimal program through the real FlatBuffer builder, one stamped
at the supported version and one above it, and check that the first loads and the second
returns InvalidProgram at the cheapest verification level. Ran the program test suite on
Linux x86_64 before and after the change: the same tests pass in both, plus the two new
ones. Deleting the check and keeping the tests makes the negative test fail, with the
newer file loading successfully, so the check is what catches it. Measured on the object
file, the cost is 24 bytes with logging disabled and 239 bytes with logging enabled.
The companion PTD file has the same unread version field, and this change does not touch
it.
CopilotAI lite review requested due to automatic review settings August 24, 2026 23:14
@pytorch-bot

pytorch-botBot commented Aug 24, 2026

Copy link
Copy Markdown

🔗 Helpful Links

🧪 See artifacts and rendered test results at hud.pytorch.org/pr/pytorch/executorch/22114

Note: Links to docs will display an error until the docs builds have been completed.

❌ 1 New Failure, 2 Unclassified Failures

As of commit 8f613a8 with merge base 1afd07f (image):

NEW FAILURE - The following job has failed:

UNCLASSIFIED FAILURES - DrCI could not classify the following jobs because the workflow did not run on the merge base. The failures may be pre-existing on trunk or introduced by this PR:

This comment was automatically generated by Dr. CI and updates every 15 minutes.

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@meta-clameta-claBot added the CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. label Aug 24, 2026
@shoumikhinshoumikhin added the release notes: runtime Changes related to the core runtime which loads the program methods, initializes delegates, and runs label Aug 24, 2026
CopilotAI review requested due to automatic review settings August 24, 2026 23:19

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

…read
The data file has the same hole the program file had. Both writers, the C++ one in
serialize.h and the Python one in serialize.py, stamp a schema version into every PTD
file, and nothing in the runtime ever read it back. The four byte identifier FT01 was the
only gate, and it says nothing about which shape of data follows.
Read the version in FlatTensorDataMap::load, right after the root table is obtained, and
refuse anything above FlatTensorDataMap::kMaxSupportedSchemaVersion with
InvalidExternalData, which is what the checks around it return. The rule is the same as
for the program file: "less than or equal", so an older data file keeps loading.
Two new tests build a minimal PTD file in memory, one stamped at the supported version
and one above it. The positive one is the control. Without it, a rejection could just as
well come from a malformed fixture as from the version. Ran the data map suite on Linux
x86_64: the six existing tests pass unchanged and both new ones pass. Deleting the check
makes the negative test load the newer file with no error at all, so the check is what
catches it. Measured on the object file, the cost is 32 bytes with logging disabled and
243 bytes with logging enabled.
CopilotAI review requested due to automatic review settings August 24, 2026 23:30

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@shoumikhinshoumikhin changed the title Refuse a PTE file whose schema version is newer than the runtime can readRefuse a PTE or PTD file whose schema version is newer than the runtime can readAug 24, 2026
The comment next to each version check said the four byte identifier "only
says that this is a program" / "FlatTensor data". That undersells it. The
identifier is itself the coarse compatibility gate: ET12 and FT01 are bumped
only on a backward-incompatible schema change, so the identifier selects a
schema family and check_header already rejects a file from a different family
as IncompatibleVersion. The new version scalar is the finer gate within one
family. Reword both comments to say that, so a future reader does not conclude
the identifier carries no version meaning. Comment-only, no behavior change.
CopilotAI review requested due to automatic review settings August 25, 2026 00:29

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@GasoonjiaGasoonjia left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for doing the BC/FC check! I think there're two questions im not sure clear:

  1. When should we update the ExecuTorch version? What's the rule behind it?
  2. Is there any ci to mandatory make the version bump happen?

@Gasoonjia

Copy link
Copy Markdown
Contributor

@claude review the PR

@claude

claudeBot commented Aug 25, 2026

Copy link
Copy Markdown

Claude finished @Gasoonjia's task in 2m 7s —— View job


PR Review: Refuse a PTE/PTD file whose schema version is newer than the runtime can read

I read the full diff plus the surrounding load paths in program.cpp and flat_tensor_data_map.cpp. This is a clean, well-scoped change and the reasoning in the description is sound. Below is my feedback, followed by answers to @Gasoonjia's two questions.

What's good

  • Both checks sit in the right place — immediately after the root table pointer is obtained (program.cpp:234, flat_tensor_data_map.cpp:301) and return the same error the surrounding code already uses (InvalidProgram / InvalidExternalData).
  • <= rather than == is the correct choice given the append-only-optional-fields FC promise, and the error messages name both numbers and tell the user what to do.
  • Because version() defaults to 0 when the field is absent, files written before the field existed still load. And since nothing stamps a non-zero version today, this is inert in the wild — low risk.
  • The positive/negative test pairing is the right shape; the program negative test using Verification::Minimal nicely proves the gate doesn't depend on full verification.

The real gap — nothing enforces the constants stay in sync

This is the same concern behind @Gasoonjia's questions. The correctness of the gate depends on four hand-maintained constants agreeing:

ConstantFile
EXECUTORCH_SCHEMA_VERSIONexir/version.py
Program::kMaxSupportedSchemaVersionruntime/executor/program.h:85
_FLAT_TENSOR_VERSIONextension/flat_tensor/serialize/serialize.py:48
kSchemaVersionextension/flat_tensor/serialize/serialize.h
FlatTensorDataMap::kMaxSupportedSchemaVersionextension/flat_tensor/flat_tensor_data_map.h

Right now the only thing tying them together is the cross-reference comments. If someone bumps the writer (exir/version.py) and forgets the reader (program.h), the failure mode is exactly the silent misread this PR is trying to prevent — the reader would reject files it can and should read (an FC regression), and the comments alone won't catch it in review.

Consider adding a tiny guard so a bump can't drift:

  • A C++ static_assert(Program::kMaxSupportedSchemaVersion == kSchemaVersion ...) where the headers are both visible, and/or
  • A Python unit test that reads both .h files (or a generated constant) and asserts equality with EXECUTORCH_SCHEMA_VERSION / _FLAT_TENSOR_VERSION.

That would answer question 2 directly and make the comments enforceable rather than advisory.

Minor

  • Reading version() before full verification. In Verification::Minimal mode only root_offset is bounds-checked (program.cpp:216); flatbuffer_program->version() then dereferences the vtable of an otherwise-unverified buffer. This is consistent with the existing code (named_data(), constant_segment() are read the same way right after), so it's not a regression — just noting the version read inherits the same trust assumptions as everything below it. No change needed.
  • Comment claim about the file identifier. The comments state the 4-byte identifier "changes only on a backward-incompatible schema change." That's the intended contract, but I don't see anything in the schema or tooling that enforces the identifier bumps on a breaking change — it's a convention. Fine to keep, just be aware it's an assertion of intent, not a guarantee the code makes.
  • reinterpret_cast<const uint32_t*>(flatbuffer) in the flat_tensor test assumes little-endian + aligned start. Acceptable for a test fixture, and it mirrors save_ptd(), but it's the fragile part of the suite if the header layout ever changes.

Answers to @Gasoonjia

  1. When should the version bump? The schemas are append-only: adding an optional field is backward- and forward-compatible and does not require a bump (that's the whole point of the <= check). A bump is only meaningful when a change makes a new file unreadable-as-old by an old runtime — i.e. a semantic change to an existing field, or a new field the runtime must understand to execute correctly. Purely additive optional fields shouldn't bump; changes that old runtimes would silently misinterpret should. It would be worth stating this rule explicitly in a comment next to EXECUTORCH_SCHEMA_VERSION, since this PR is what gives the number teeth.
  2. Is there CI to mandate the bump? Not that I can find, and this PR doesn't add one. There's no check that a schema (.fbs) change forces a version bump, nor that the writer/reader constants match. The sync-assert / test suggested above covers the second half (constants agreeing); mandating a bump on .fbs changes is harder and probably out of scope here, but worth a follow-up issue.

Overall: correct and low-risk as written. The one thing I'd push for before merge is a mechanical guard that the writer and reader constants can't diverge — otherwise the whole mechanism is one forgotten edit away from silently regressing FC.
• branch shoumikhin/program-version-gate

CopilotAI review requested due to automatic review settings August 25, 2026 01:16

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

The version gate this PR adds is only correct while five hand-maintained
constants agree: the PTE writer (EXECUTORCH_SCHEMA_VERSION) and its runtime
reader (Program::kMaxSupportedSchemaVersion), and the two PTD writers
(_FLAT_TENSOR_VERSION, kSchemaVersion) and their reader
(FlatTensorDataMap::kMaxSupportedSchemaVersion). Until now only cross-reference
comments tied them together, so bumping a writer without its reader would make a
runtime refuse files it should read, and bumping one PTD writer without the
other would let a file be stamped below its real layout and misread.
Extend schema/test/test_schema.py, which already enforces cross-file schema
sync and runs in OSS CI with no build wiring, with a check that parses the five
literals as text and asserts the compatibility relationship: each writer must be
<= its reader ceiling (a reader may support a version before any writer emits
it, but never the reverse), and the two PTD writers must be exactly equal
because they stamp the same field of the same file. The parse keys on file path,
not symbol, since the two reader ceilings share a name, and fails closed if a
constant can no longer be found so a reformat can't silently disable the guard.
Also state the bump rule next to EXECUTORCH_SCHEMA_VERSION and soften the
file-identifier comments: the identifier is bumped by convention on a breaking
change, not by anything the code enforces.
CopilotAI review requested due to automatic review settings August 25, 2026 01:22

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@shoumikhin

Copy link
Copy Markdown
ContributorAuthor

Thanks @claude — good catch on the drift risk. I added the guard, and it's the right call to do it in this PR since this is what turns the constants into a real gate.

What I added: a check in schema/test/test_schema.py (the file that already enforces cross-file schema sync and runs in OSS CI, so no new build wiring). It reads all five version constants as text and asserts:

  • PTE: EXECUTORCH_SCHEMA_VERSIONProgram::kMaxSupportedSchemaVersion
  • PTD: _FLAT_TENSOR_VERSION == kSchemaVersionFlatTensorDataMap::kMaxSupportedSchemaVersion

The relationship is writer-≤-reader, not strict equality: a runtime is allowed to support a version before any writer emits it (forward-compat), but a writer must never stamp a version its reader would refuse. The two PTD writers are compared with == because they stamp the same field of the same file. The parse keys on the file path (the two reader ceilings share the name kMaxSupportedSchemaVersion) and fails if a constant can't be found, so a rename/reformat can't silently disable the check.

I verified it locally: passes today, fails when I bump one writer without its reader, fails when the two PTD writers disagree, and still passes when only a reader is bumped ahead (the legitimate case).

On the minors:

  • Softened both file-identifier comments to say the tag is bumped by convention on a breaking change — you're right it's not code-enforced.
  • Added the bump rule you described next to EXECUTORCH_SCHEMA_VERSION.
  • Left the version()-before-verification read and the test's reinterpret_cast as-is, since you flagged both as no-change-needed (the version read inherits the same trust as the reads around it; the cast mirrors save_ptd()).

On mandating a bump when a .fbs changes: still out of scope here (it needs schema-shape diffing), but the constant-sync half is now covered. Happy to file a follow-up for the .fbs-change mandate and for the Python PTD reader still using != (separate small PR).

@shoumikhin

Copy link
Copy Markdown
ContributorAuthor

@claude review the PR

@claude

claudeBot commented Aug 25, 2026

Copy link
Copy Markdown

Claude finished @shoumikhin's task in 1m 32s —— View job


Re: the drift guard (8f613a8)

I read the new test_schema.py::test_schema_version_constants_in_sync and traced all five regexes against the actual declarations. This resolves the concern from my earlier review — the guard is correct and lands in the right place.

Verified the five patterns match today's source:

ConstantDeclarationRegex
EXECUTORCH_SCHEMA_VERSION= 0 (exir/version.py:14)
Program::kMaxSupportedSchemaVersion= 0 (program.h:85)
_FLAT_TENSOR_VERSION: int = 0 (serialize.py:49)
kSchemaVersion= 0 (serialize.h:26)
FlatTensorDataMap::kMaxSupportedSchemaVersion= 0 (flat_tensor_data_map.h:47)

The design decisions are right:

  • Keying on file path, not symbol name is the correct call — the two reader ceilings both spell kMaxSupportedSchemaVersion, so a symbol-based parse would be ambiguous. Path-keyed re.search on each file avoids that, and since kMaxSupportedSchemaVersion doesn't contain the literal substring kSchemaVersion, the PTD-writer regex in serialize.h can't accidentally match a ceiling either.
  • Writer <= reader, PTD writers == matches the compatibility policy exactly: a runtime may support a version before any writer emits it (FC), but a writer must never stamp a version its reader refuses; and the two PTD writers stamp the same field, so they must be equal. The asymmetry is intentional and correctly encoded.
  • Fail-closed on a missing constant (assertIsNotNone with a message that tells the next editor to fix the test) is what makes a rename/reformat surface loudly instead of silently disabling the guard — this was the exact failure mode I was worried about.
  • Putting it in test_schema.py (already in OSS CI, no new build wiring) is the pragmatic choice over a static_assert, which couldn't reach the Python writers anyway.

On the minors — all handled as expected: the file-identifier comments now say "by convention," the bump rule is stated next to EXECUTORCH_SCHEMA_VERSION, and leaving the version()-before-verification read and the test reinterpret_cast as-is is fine.

One small note (non-blocking): the guard proves the five constants agree with each other, but the writer/reader ceiling and the actual serialized field are still tied only by the runtime code that reads version(). That's fine — that path is what your four new C++ tests exercise, so the two halves (constants-agree here, field-is-gated there) are each covered.

On follow-ups: filing separate issues for (a) mandating a version bump on .fbs shape changes and (b) the Python PTD reader still using != instead of <= sounds right — both are genuinely out of scope for this PR, and the constant-sync half was the load-bearing gap.

I couldn't run the suite here (sandbox blocked python), but the parse is simple text matching and I verified each pattern against its literal by hand; your local run (passes today, fails on each induced drift) covers the dynamic behavior.

LGTM.
• branch shoumikhin/program-version-gate

@claude

claudeBot commented Aug 25, 2026

Copy link
Copy Markdown

Claude finished @shoumikhin's task in 2m 4s —— View job


Re-review: Refuse a PTE/PTD file whose schema version is newer than the runtime can read

I re-read the full diff against origin/main, focused on the commit added since my last pass (the constant-sync guard) plus a fresh look at the readers and tests. My earlier concern — that the whole mechanism was one forgotten edit away from silently regressing FC — is now addressed. Summary: correct, low-risk, and I'd merge it.

The drift guard resolves the main gap

schema/test/test_schema.py::test_schema_version_constants_in_sync is the right fix in the right place — it lives in the file that already enforces cross-file schema sync and runs in OSS CI, so no new build wiring. Reviewing the logic:

  • Text-parse over import is the correct call here (importing the writers drags in torch; the C++ constants have no Python binding), and keying the parse on file path rather than symbol name correctly disambiguates the two reader ceilings that share kMaxSupportedSchemaVersion (program.h:80, flat_tensor_data_map.h:46).
  • The assertIsNotNone on every match means a rename or reformat fails the test loudly instead of silently disabling the check — exactly the failure mode you'd want to avoid in a guard like this.
  • The relations model the compatibility policy correctly: writer--reader for each family (a runtime may support a version before any writer emits it), and == between the two PTD writers (they stamp the same field of the same file). PTE and PTD kept independent.

Readers — verified placement

  • Program::load (program.cpp:226) — the check sits right after the root-table pointer and before named_data/constant_segment reads, returning InvalidProgram. Correct.
  • FlatTensorDataMap (flat_tensor_data_map.cpp:301) — same shape, returns InvalidExternalData. Correct.
  • <= (not ==), version defaulting to 0 when absent, and both numbers named in the message with an actionable fix — all as described.

Tests

  • The positive/negative pairing per format is the right shape, and the program negative test using Verification::Minimal proves the gate doesn't depend on full verification. The negative fixtures are otherwise-valid files differing only in the version field, so the rejection is attributable to the gate and nothing else.
  • The flat_tensor fixture's reinterpret_cast<const uint32_t*>(flatbuffer) assumes little-endian + aligned start — fine for a test fixture and it mirrors save_ptd(), as previously noted. No change needed.

Minors addressed

  • Both file-identifier comments now say the tag is bumped by convention — matches reality (the identifier isn't code-enforced to bump on a break).
  • The bump rule is now documented next to EXECUTORCH_SCHEMA_VERSION (exir/version.py), which is what gives the number teeth.

One optional follow-up (non-blocking)

As you noted, the guard covers the constants agreeing half. It does not mandate a version bump when a .fbs actually changes shape — that needs schema-shape diffing and is reasonably out of scope. Worth a follow-up issue, along with the Python PTD reader still comparing with != (serialize.py) rather than the <= semantics this PR establishes on the C++ side, so a future non-zero PTD version wouldn't regress the Python path.

Nothing here blocks merge. Nice work turning the cross-reference comments into an enforced check within this same PR.
• branch shoumikhin/program-version-gate

<cstring> and <vector> already arrive through buffer_data_loader.h and the
generated flatbuffers header, so drop them. Replace the per-symbol using
declarations with namespace directives, matching the convention used elsewhere.
CopilotAI review requested due to automatic review settings August 25, 2026 02:07

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@shoumikhin
shoumikhin merged commit d181029 into mainAug 25, 2026
212 of 213 checks passed
@shoumikhin
shoumikhin deleted the shoumikhin/program-version-gate branch August 25, 2026 02:44
shoumikhin added a commit that referenced this pull request Aug 25, 2026
…22117)
### Summary
The Python `FlatTensorSerializer.deserialize` refused any PTD file whose
`version` did not **exactly** equal `_FLAT_TENSOR_VERSION`:
```python
if flat_tensor.version != _FLAT_TENSOR_VERSION:
raise NotImplementedError(...)
```
That rejects an **older** file too, which contradicts:
- the append-only schema policy in `schema/README.md` (older files stay
loadable), and
- the C++ runtime readers, which accept anything `<=` their supported
version and only refuse a file **newer** than they understand
(`Program::load`, `FlatTensorDataMap::load`).
This aligns the Python PTD reader with that policy: compare with `>`
instead of `!=`, so an older or equal file loads and only a newer one is
refused. The error message now says the file is newer than this reader
supports.
### Context
This was called out as a follow-up during review of #22114 (which adds
the `<=` gate on the C++ side). It keeps the Python export/tooling path
consistent so a future non-zero PTD version won't regress it.
### Test plan
Two tests in `extension/flat_tensor/test/test_serialize.py`:
- `test_deserialize_refuses_newer_version` — a bumped-version file is
refused.
- `test_deserialize_accepts_older_version` — a file older than the
reader still loads (the case the old `!=` wrongly rejected).
Both are behavior-only; no schema or format change. Version constants
are unchanged (still `0`), so this is inert for existing files and only
changes behavior once a non-zero version is ever stamped.
shoumikhin added a commit that referenced this pull request Aug 25, 2026
### Summary
`FlatTensorDataMap::load()` reads the flatbuffer root table without
first bounds-checking the root offset:
```cpp
const flat_tensor_flatbuffer::FlatTensor* flat_tensor =
flat_tensor_flatbuffer::GetFlatTensor(flat_tensor_data->data());
// then immediately:
flat_tensor->named_data(); // walks the root vtable
flat_tensor->segments();
```
`GetFlatTensor()` interprets the `uoffset_t` at the start of the buffer
as the root-table offset and returns `buf + offset`. The first field
access then walks that table's vtable. Nothing on this path runs full
flatbuffer verification, so a corrupt or truncated PTD file with a bad
root offset makes these accessors dereference memory **outside the
buffer** (an OOB read, flagged by ASan).
The program loader already guards against exactly this in
`runtime/executor/program.cpp`. This change mirrors that guard for PTD
files.
### What changed
After the existing identifier and alignment checks, before
`GetFlatTensor()`:
- Confirm the buffer is at least a flatbuffer header long (`uoffset_t` +
file identifier).
- Confirm the root offset is `>= kMinBufferSize` and leaves room for a
vtable `soffset_t` within the buffer.
- Return `InvalidExternalData` otherwise (the same error the surrounding
checks use).
FlatTensor is **not** size-prefixed — both writers finish the buffer
plain (`builder.Finish` in C++, default `flatc` in Python) and the
reader uses `GetFlatTensor` (not the size-prefixed variant) — so the
root offset is at byte 0, exactly as in the program case.
### Scope
This is a pre-existing hardening, independent of the schema-version work
in #22114 (the version gate this protects is additive; the OOB path
exists today via `named_data()`/`segments()`).
### Test plan
New `FlatTensorDataMapTest.RejectsOutOfBoundsRootOffset`: copies a valid
PTD into a max-aligned buffer, overwrites only the root offset with a
value past the end (leaving identifier + alignment intact), and asserts
`load()` returns `InvalidExternalData`. Without the check, that same
input is an out-of-bounds vtable read under ASan.
> Note: I was unable to build/run the C++ suite in my local environment,
so I'm relying on this PR's CI (which builds the runtime and runs
`extension/flat_tensor` tests, including under ASan) as the
authoritative check. The change mirrors an existing, tested guard in
`program.cpp`.
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA SignedThis label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed.release notes: runtimeChanges related to the core runtime which loads the program methods, initializes delegates, and runs

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants

@shoumikhin@Gasoonjia@huydhn
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Refuse a PTE or PTD file whose schema version is newer than the runtime can read - #22114

Merged
shoumikhin merged 8 commits into
mainfrom
shoumikhin/program-version-gate
Aug 25, 2026
Merged

Refuse a PTE or PTD file whose schema version is newer than the runtime can read#22114
shoumikhin merged 8 commits into
mainfrom
shoumikhin/program-version-gate

Conversation

@shoumikhin

@shoumikhinshoumikhin commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

Summary

Every exported PTE file and every PTD data file carries a schema version. The exporters stamp it, and nothing in the runtime ever read it back. The only gate on a file was its four byte identifier, ET12 for a program and FT01 for data. That says "this is an ExecuTorch file" and says nothing about which shape of file it is. A file written by an exporter newer than the runtime was therefore accepted, then misread field by field, and failed later at load or during execution with an error that points somewhere else.

This matters more now that the two halves can come from different builds. The wheel ships prebuilt runtime libraries, so a user can export with one installation of ExecuTorch and run with a runtime that was never built next to it.

This PR reads the version in both readers, right after the root table is obtained, and refuses anything above the highest version the runtime supports. Program::load returns InvalidProgram, FlatTensorDataMap::load returns InvalidExternalData, which is what the checks around each of them return. Both name the two numbers:

Program schema version 1 is newer than the highest this runtime supports (0).
Export the model with an older ExecuTorch, or update the runtime.

The comparison is "less than or equal", not "equal", because the project promises that an older file keeps working on a newer runtime, and the schemas only ever grow by appending optional fields. Only a file from the future is refused.

Each new constant sits next to the class it guards, and points in a comment at the writer constants for the same number: EXECUTORCH_SCHEMA_VERSION for the program, kSchemaVersion and _FLAT_TENSOR_VERSION for the data file. A bump of one without the other is then easy to spot. Nothing writes a version other than zero today, so no file in the wild changes behavior. This is the reader side that a future version bump needs in order to mean anything.

One limit worth stating plainly: a runtime that is already deployed has no check at all, so this protects runtimes built from this change onward. That is inherent to adding a reader.

Test plan

Four new tests, two per format. Each pair builds a minimal file in memory through the real FlatBuffer builder, one stamped at the supported version and one above it. The first is expected to load and the second to be refused. The positive test is the control: without it, a rejection could just as well come from a malformed fixture as from the version. The program test asks for the cheapest verification level, which shows the check does not depend on full verification being enabled. The data test follows the byte layout that save_ptd() writes, with no segments.

Built and ran both suites on Linux x86_64, before and after the change:

cmake -S . -B cmake-out -DEXECUTORCH_BUILD_TESTS=ON \
-DEXECUTORCH_ENABLE_PROGRAM_VERIFICATION=ON \
-DEXECUTORCH_BUILD_EXTENSION_DATA_LOADER=ON \
-DEXECUTORCH_BUILD_EXTENSION_FLAT_TENSOR=ON
cmake --build cmake-out -j
./cmake-out/runtime/executor/test/program_test
./cmake-out/extension/flat_tensor/test/extension_flat_tensor_test

The existing tests pass unchanged in both runs, plus the four new ones. As a control, deleting either check while keeping the tests makes that format's negative test fail, with the newer file loading and reporting no error, so the check is what catches it.

Binary size, measured on the two object files. program.cpp.o grows by 24 bytes with logging disabled and 239 bytes with logging enabled. flat_tensor_data_map.cpp.o grows by 32 bytes with logging disabled and 243 bytes with logging enabled.

…read
Every exported PTE file carries a schema version. The exporter stamps it from
EXECUTORCH_SCHEMA_VERSION, and nothing in the runtime ever read it back. The only gate
on a file was the four byte identifier ET12, which says "this is an ExecuTorch program"
and says nothing about which shape of program it is. A file written by an exporter newer
than the runtime was therefore accepted, then misread field by field, and failed later
at load or during execution with an error that points somewhere else.
This matters more now that the two halves can come from different builds. The wheel
ships prebuilt runtime libraries, so a user can export with one installation of
ExecuTorch and run with a runtime that was never built next to it.
Read the version in Program::load, right after the root table is obtained, and refuse
anything above Program::kMaxSupportedSchemaVersion with InvalidProgram and a message
that names both numbers. The comparison is "less than or equal", not "equal", because
the project promises that an older file keeps working on a newer runtime, and the schema
only ever grows by appending optional fields. Only a file from the future is refused.
The new constant sits next to the class it guards, and the two constants point at each
other in comments so that a bump of one without the other is easy to spot. Nothing
writes a version other than zero today, so no file in the wild changes behavior. This is
the reader side that a future version bump needs in order to mean anything.
Two new tests build a minimal program through the real FlatBuffer builder, one stamped
at the supported version and one above it, and check that the first loads and the second
returns InvalidProgram at the cheapest verification level. Ran the program test suite on
Linux x86_64 before and after the change: the same tests pass in both, plus the two new
ones. Deleting the check and keeping the tests makes the negative test fail, with the
newer file loading successfully, so the check is what catches it. Measured on the object
file, the cost is 24 bytes with logging disabled and 239 bytes with logging enabled.
The companion PTD file has the same unread version field, and this change does not touch
it.
CopilotAI lite review requested due to automatic review settings August 24, 2026 23:14
@pytorch-bot

pytorch-botBot commented Aug 24, 2026

Copy link
Copy Markdown

🔗 Helpful Links

🧪 See artifacts and rendered test results at hud.pytorch.org/pr/pytorch/executorch/22114

Note: Links to docs will display an error until the docs builds have been completed.

❌ 1 New Failure, 2 Unclassified Failures

As of commit 8f613a8 with merge base 1afd07f (image):

NEW FAILURE - The following job has failed:

UNCLASSIFIED FAILURES - DrCI could not classify the following jobs because the workflow did not run on the merge base. The failures may be pre-existing on trunk or introduced by this PR:

This comment was automatically generated by Dr. CI and updates every 15 minutes.

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@meta-clameta-claBot added the CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. label Aug 24, 2026
@shoumikhinshoumikhin added the release notes: runtime Changes related to the core runtime which loads the program methods, initializes delegates, and runs label Aug 24, 2026
CopilotAI review requested due to automatic review settings August 24, 2026 23:19

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

…read
The data file has the same hole the program file had. Both writers, the C++ one in
serialize.h and the Python one in serialize.py, stamp a schema version into every PTD
file, and nothing in the runtime ever read it back. The four byte identifier FT01 was the
only gate, and it says nothing about which shape of data follows.
Read the version in FlatTensorDataMap::load, right after the root table is obtained, and
refuse anything above FlatTensorDataMap::kMaxSupportedSchemaVersion with
InvalidExternalData, which is what the checks around it return. The rule is the same as
for the program file: "less than or equal", so an older data file keeps loading.
Two new tests build a minimal PTD file in memory, one stamped at the supported version
and one above it. The positive one is the control. Without it, a rejection could just as
well come from a malformed fixture as from the version. Ran the data map suite on Linux
x86_64: the six existing tests pass unchanged and both new ones pass. Deleting the check
makes the negative test load the newer file with no error at all, so the check is what
catches it. Measured on the object file, the cost is 32 bytes with logging disabled and
243 bytes with logging enabled.
CopilotAI review requested due to automatic review settings August 24, 2026 23:30

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@shoumikhinshoumikhin changed the title Refuse a PTE file whose schema version is newer than the runtime can readRefuse a PTE or PTD file whose schema version is newer than the runtime can readAug 24, 2026
The comment next to each version check said the four byte identifier "only
says that this is a program" / "FlatTensor data". That undersells it. The
identifier is itself the coarse compatibility gate: ET12 and FT01 are bumped
only on a backward-incompatible schema change, so the identifier selects a
schema family and check_header already rejects a file from a different family
as IncompatibleVersion. The new version scalar is the finer gate within one
family. Reword both comments to say that, so a future reader does not conclude
the identifier carries no version meaning. Comment-only, no behavior change.
CopilotAI review requested due to automatic review settings August 25, 2026 00:29

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@GasoonjiaGasoonjia left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for doing the BC/FC check! I think there're two questions im not sure clear:

  1. When should we update the ExecuTorch version? What's the rule behind it?
  2. Is there any ci to mandatory make the version bump happen?

@Gasoonjia

Copy link
Copy Markdown
Contributor

@claude review the PR

@claude

claudeBot commented Aug 25, 2026

Copy link
Copy Markdown

Claude finished @Gasoonjia's task in 2m 7s —— View job


PR Review: Refuse a PTE/PTD file whose schema version is newer than the runtime can read

I read the full diff plus the surrounding load paths in program.cpp and flat_tensor_data_map.cpp. This is a clean, well-scoped change and the reasoning in the description is sound. Below is my feedback, followed by answers to @Gasoonjia's two questions.

What's good

  • Both checks sit in the right place — immediately after the root table pointer is obtained (program.cpp:234, flat_tensor_data_map.cpp:301) and return the same error the surrounding code already uses (InvalidProgram / InvalidExternalData).
  • <= rather than == is the correct choice given the append-only-optional-fields FC promise, and the error messages name both numbers and tell the user what to do.
  • Because version() defaults to 0 when the field is absent, files written before the field existed still load. And since nothing stamps a non-zero version today, this is inert in the wild — low risk.
  • The positive/negative test pairing is the right shape; the program negative test using Verification::Minimal nicely proves the gate doesn't depend on full verification.

The real gap — nothing enforces the constants stay in sync

This is the same concern behind @Gasoonjia's questions. The correctness of the gate depends on four hand-maintained constants agreeing:

ConstantFile
EXECUTORCH_SCHEMA_VERSIONexir/version.py
Program::kMaxSupportedSchemaVersionruntime/executor/program.h:85
_FLAT_TENSOR_VERSIONextension/flat_tensor/serialize/serialize.py:48
kSchemaVersionextension/flat_tensor/serialize/serialize.h
FlatTensorDataMap::kMaxSupportedSchemaVersionextension/flat_tensor/flat_tensor_data_map.h

Right now the only thing tying them together is the cross-reference comments. If someone bumps the writer (exir/version.py) and forgets the reader (program.h), the failure mode is exactly the silent misread this PR is trying to prevent — the reader would reject files it can and should read (an FC regression), and the comments alone won't catch it in review.

Consider adding a tiny guard so a bump can't drift:

  • A C++ static_assert(Program::kMaxSupportedSchemaVersion == kSchemaVersion ...) where the headers are both visible, and/or
  • A Python unit test that reads both .h files (or a generated constant) and asserts equality with EXECUTORCH_SCHEMA_VERSION / _FLAT_TENSOR_VERSION.

That would answer question 2 directly and make the comments enforceable rather than advisory.

Minor

  • Reading version() before full verification. In Verification::Minimal mode only root_offset is bounds-checked (program.cpp:216); flatbuffer_program->version() then dereferences the vtable of an otherwise-unverified buffer. This is consistent with the existing code (named_data(), constant_segment() are read the same way right after), so it's not a regression — just noting the version read inherits the same trust assumptions as everything below it. No change needed.
  • Comment claim about the file identifier. The comments state the 4-byte identifier "changes only on a backward-incompatible schema change." That's the intended contract, but I don't see anything in the schema or tooling that enforces the identifier bumps on a breaking change — it's a convention. Fine to keep, just be aware it's an assertion of intent, not a guarantee the code makes.
  • reinterpret_cast<const uint32_t*>(flatbuffer) in the flat_tensor test assumes little-endian + aligned start. Acceptable for a test fixture, and it mirrors save_ptd(), but it's the fragile part of the suite if the header layout ever changes.

Answers to @Gasoonjia

  1. When should the version bump? The schemas are append-only: adding an optional field is backward- and forward-compatible and does not require a bump (that's the whole point of the <= check). A bump is only meaningful when a change makes a new file unreadable-as-old by an old runtime — i.e. a semantic change to an existing field, or a new field the runtime must understand to execute correctly. Purely additive optional fields shouldn't bump; changes that old runtimes would silently misinterpret should. It would be worth stating this rule explicitly in a comment next to EXECUTORCH_SCHEMA_VERSION, since this PR is what gives the number teeth.
  2. Is there CI to mandate the bump? Not that I can find, and this PR doesn't add one. There's no check that a schema (.fbs) change forces a version bump, nor that the writer/reader constants match. The sync-assert / test suggested above covers the second half (constants agreeing); mandating a bump on .fbs changes is harder and probably out of scope here, but worth a follow-up issue.

Overall: correct and low-risk as written. The one thing I'd push for before merge is a mechanical guard that the writer and reader constants can't diverge — otherwise the whole mechanism is one forgotten edit away from silently regressing FC.
• branch shoumikhin/program-version-gate

CopilotAI review requested due to automatic review settings August 25, 2026 01:16

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

The version gate this PR adds is only correct while five hand-maintained
constants agree: the PTE writer (EXECUTORCH_SCHEMA_VERSION) and its runtime
reader (Program::kMaxSupportedSchemaVersion), and the two PTD writers
(_FLAT_TENSOR_VERSION, kSchemaVersion) and their reader
(FlatTensorDataMap::kMaxSupportedSchemaVersion). Until now only cross-reference
comments tied them together, so bumping a writer without its reader would make a
runtime refuse files it should read, and bumping one PTD writer without the
other would let a file be stamped below its real layout and misread.
Extend schema/test/test_schema.py, which already enforces cross-file schema
sync and runs in OSS CI with no build wiring, with a check that parses the five
literals as text and asserts the compatibility relationship: each writer must be
<= its reader ceiling (a reader may support a version before any writer emits
it, but never the reverse), and the two PTD writers must be exactly equal
because they stamp the same field of the same file. The parse keys on file path,
not symbol, since the two reader ceilings share a name, and fails closed if a
constant can no longer be found so a reformat can't silently disable the guard.
Also state the bump rule next to EXECUTORCH_SCHEMA_VERSION and soften the
file-identifier comments: the identifier is bumped by convention on a breaking
change, not by anything the code enforces.
CopilotAI review requested due to automatic review settings August 25, 2026 01:22

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@shoumikhin

Copy link
Copy Markdown
ContributorAuthor

Thanks @claude — good catch on the drift risk. I added the guard, and it's the right call to do it in this PR since this is what turns the constants into a real gate.

What I added: a check in schema/test/test_schema.py (the file that already enforces cross-file schema sync and runs in OSS CI, so no new build wiring). It reads all five version constants as text and asserts:

  • PTE: EXECUTORCH_SCHEMA_VERSIONProgram::kMaxSupportedSchemaVersion
  • PTD: _FLAT_TENSOR_VERSION == kSchemaVersionFlatTensorDataMap::kMaxSupportedSchemaVersion

The relationship is writer-≤-reader, not strict equality: a runtime is allowed to support a version before any writer emits it (forward-compat), but a writer must never stamp a version its reader would refuse. The two PTD writers are compared with == because they stamp the same field of the same file. The parse keys on the file path (the two reader ceilings share the name kMaxSupportedSchemaVersion) and fails if a constant can't be found, so a rename/reformat can't silently disable the check.

I verified it locally: passes today, fails when I bump one writer without its reader, fails when the two PTD writers disagree, and still passes when only a reader is bumped ahead (the legitimate case).

On the minors:

  • Softened both file-identifier comments to say the tag is bumped by convention on a breaking change — you're right it's not code-enforced.
  • Added the bump rule you described next to EXECUTORCH_SCHEMA_VERSION.
  • Left the version()-before-verification read and the test's reinterpret_cast as-is, since you flagged both as no-change-needed (the version read inherits the same trust as the reads around it; the cast mirrors save_ptd()).

On mandating a bump when a .fbs changes: still out of scope here (it needs schema-shape diffing), but the constant-sync half is now covered. Happy to file a follow-up for the .fbs-change mandate and for the Python PTD reader still using != (separate small PR).

@shoumikhin

Copy link
Copy Markdown
ContributorAuthor

@claude review the PR

@claude

claudeBot commented Aug 25, 2026

Copy link
Copy Markdown

Claude finished @shoumikhin's task in 1m 32s —— View job


Re: the drift guard (8f613a8)

I read the new test_schema.py::test_schema_version_constants_in_sync and traced all five regexes against the actual declarations. This resolves the concern from my earlier review — the guard is correct and lands in the right place.

Verified the five patterns match today's source:

ConstantDeclarationRegex
EXECUTORCH_SCHEMA_VERSION= 0 (exir/version.py:14)
Program::kMaxSupportedSchemaVersion= 0 (program.h:85)
_FLAT_TENSOR_VERSION: int = 0 (serialize.py:49)
kSchemaVersion= 0 (serialize.h:26)
FlatTensorDataMap::kMaxSupportedSchemaVersion= 0 (flat_tensor_data_map.h:47)

The design decisions are right:

  • Keying on file path, not symbol name is the correct call — the two reader ceilings both spell kMaxSupportedSchemaVersion, so a symbol-based parse would be ambiguous. Path-keyed re.search on each file avoids that, and since kMaxSupportedSchemaVersion doesn't contain the literal substring kSchemaVersion, the PTD-writer regex in serialize.h can't accidentally match a ceiling either.
  • Writer <= reader, PTD writers == matches the compatibility policy exactly: a runtime may support a version before any writer emits it (FC), but a writer must never stamp a version its reader refuses; and the two PTD writers stamp the same field, so they must be equal. The asymmetry is intentional and correctly encoded.
  • Fail-closed on a missing constant (assertIsNotNone with a message that tells the next editor to fix the test) is what makes a rename/reformat surface loudly instead of silently disabling the guard — this was the exact failure mode I was worried about.
  • Putting it in test_schema.py (already in OSS CI, no new build wiring) is the pragmatic choice over a static_assert, which couldn't reach the Python writers anyway.

On the minors — all handled as expected: the file-identifier comments now say "by convention," the bump rule is stated next to EXECUTORCH_SCHEMA_VERSION, and leaving the version()-before-verification read and the test reinterpret_cast as-is is fine.

One small note (non-blocking): the guard proves the five constants agree with each other, but the writer/reader ceiling and the actual serialized field are still tied only by the runtime code that reads version(). That's fine — that path is what your four new C++ tests exercise, so the two halves (constants-agree here, field-is-gated there) are each covered.

On follow-ups: filing separate issues for (a) mandating a version bump on .fbs shape changes and (b) the Python PTD reader still using != instead of <= sounds right — both are genuinely out of scope for this PR, and the constant-sync half was the load-bearing gap.

I couldn't run the suite here (sandbox blocked python), but the parse is simple text matching and I verified each pattern against its literal by hand; your local run (passes today, fails on each induced drift) covers the dynamic behavior.

LGTM.
• branch shoumikhin/program-version-gate

@claude

claudeBot commented Aug 25, 2026

Copy link
Copy Markdown

Claude finished @shoumikhin's task in 2m 4s —— View job


Re-review: Refuse a PTE/PTD file whose schema version is newer than the runtime can read

I re-read the full diff against origin/main, focused on the commit added since my last pass (the constant-sync guard) plus a fresh look at the readers and tests. My earlier concern — that the whole mechanism was one forgotten edit away from silently regressing FC — is now addressed. Summary: correct, low-risk, and I'd merge it.

The drift guard resolves the main gap

schema/test/test_schema.py::test_schema_version_constants_in_sync is the right fix in the right place — it lives in the file that already enforces cross-file schema sync and runs in OSS CI, so no new build wiring. Reviewing the logic:

  • Text-parse over import is the correct call here (importing the writers drags in torch; the C++ constants have no Python binding), and keying the parse on file path rather than symbol name correctly disambiguates the two reader ceilings that share kMaxSupportedSchemaVersion (program.h:80, flat_tensor_data_map.h:46).
  • The assertIsNotNone on every match means a rename or reformat fails the test loudly instead of silently disabling the check — exactly the failure mode you'd want to avoid in a guard like this.
  • The relations model the compatibility policy correctly: writer--reader for each family (a runtime may support a version before any writer emits it), and == between the two PTD writers (they stamp the same field of the same file). PTE and PTD kept independent.

Readers — verified placement

  • Program::load (program.cpp:226) — the check sits right after the root-table pointer and before named_data/constant_segment reads, returning InvalidProgram. Correct.
  • FlatTensorDataMap (flat_tensor_data_map.cpp:301) — same shape, returns InvalidExternalData. Correct.
  • <= (not ==), version defaulting to 0 when absent, and both numbers named in the message with an actionable fix — all as described.

Tests

  • The positive/negative pairing per format is the right shape, and the program negative test using Verification::Minimal proves the gate doesn't depend on full verification. The negative fixtures are otherwise-valid files differing only in the version field, so the rejection is attributable to the gate and nothing else.
  • The flat_tensor fixture's reinterpret_cast<const uint32_t*>(flatbuffer) assumes little-endian + aligned start — fine for a test fixture and it mirrors save_ptd(), as previously noted. No change needed.

Minors addressed

  • Both file-identifier comments now say the tag is bumped by convention — matches reality (the identifier isn't code-enforced to bump on a break).
  • The bump rule is now documented next to EXECUTORCH_SCHEMA_VERSION (exir/version.py), which is what gives the number teeth.

One optional follow-up (non-blocking)

As you noted, the guard covers the constants agreeing half. It does not mandate a version bump when a .fbs actually changes shape — that needs schema-shape diffing and is reasonably out of scope. Worth a follow-up issue, along with the Python PTD reader still comparing with != (serialize.py) rather than the <= semantics this PR establishes on the C++ side, so a future non-zero PTD version wouldn't regress the Python path.

Nothing here blocks merge. Nice work turning the cross-reference comments into an enforced check within this same PR.
• branch shoumikhin/program-version-gate

<cstring> and <vector> already arrive through buffer_data_loader.h and the
generated flatbuffers header, so drop them. Replace the per-symbol using
declarations with namespace directives, matching the convention used elsewhere.
CopilotAI review requested due to automatic review settings August 25, 2026 02:07

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@shoumikhin
shoumikhin merged commit d181029 into mainAug 25, 2026
212 of 213 checks passed
@shoumikhin
shoumikhin deleted the shoumikhin/program-version-gate branch August 25, 2026 02:44
shoumikhin added a commit that referenced this pull request Aug 25, 2026
…22117)
### Summary
The Python `FlatTensorSerializer.deserialize` refused any PTD file whose
`version` did not **exactly** equal `_FLAT_TENSOR_VERSION`:
```python
if flat_tensor.version != _FLAT_TENSOR_VERSION:
raise NotImplementedError(...)
```
That rejects an **older** file too, which contradicts:
- the append-only schema policy in `schema/README.md` (older files stay
loadable), and
- the C++ runtime readers, which accept anything `<=` their supported
version and only refuse a file **newer** than they understand
(`Program::load`, `FlatTensorDataMap::load`).
This aligns the Python PTD reader with that policy: compare with `>`
instead of `!=`, so an older or equal file loads and only a newer one is
refused. The error message now says the file is newer than this reader
supports.
### Context
This was called out as a follow-up during review of #22114 (which adds
the `<=` gate on the C++ side). It keeps the Python export/tooling path
consistent so a future non-zero PTD version won't regress it.
### Test plan
Two tests in `extension/flat_tensor/test/test_serialize.py`:
- `test_deserialize_refuses_newer_version` — a bumped-version file is
refused.
- `test_deserialize_accepts_older_version` — a file older than the
reader still loads (the case the old `!=` wrongly rejected).
Both are behavior-only; no schema or format change. Version constants
are unchanged (still `0`), so this is inert for existing files and only
changes behavior once a non-zero version is ever stamped.
shoumikhin added a commit that referenced this pull request Aug 25, 2026
### Summary
`FlatTensorDataMap::load()` reads the flatbuffer root table without
first bounds-checking the root offset:
```cpp
const flat_tensor_flatbuffer::FlatTensor* flat_tensor =
flat_tensor_flatbuffer::GetFlatTensor(flat_tensor_data->data());
// then immediately:
flat_tensor->named_data(); // walks the root vtable
flat_tensor->segments();
```
`GetFlatTensor()` interprets the `uoffset_t` at the start of the buffer
as the root-table offset and returns `buf + offset`. The first field
access then walks that table's vtable. Nothing on this path runs full
flatbuffer verification, so a corrupt or truncated PTD file with a bad
root offset makes these accessors dereference memory **outside the
buffer** (an OOB read, flagged by ASan).
The program loader already guards against exactly this in
`runtime/executor/program.cpp`. This change mirrors that guard for PTD
files.
### What changed
After the existing identifier and alignment checks, before
`GetFlatTensor()`:
- Confirm the buffer is at least a flatbuffer header long (`uoffset_t` +
file identifier).
- Confirm the root offset is `>= kMinBufferSize` and leaves room for a
vtable `soffset_t` within the buffer.
- Return `InvalidExternalData` otherwise (the same error the surrounding
checks use).
FlatTensor is **not** size-prefixed — both writers finish the buffer
plain (`builder.Finish` in C++, default `flatc` in Python) and the
reader uses `GetFlatTensor` (not the size-prefixed variant) — so the
root offset is at byte 0, exactly as in the program case.
### Scope
This is a pre-existing hardening, independent of the schema-version work
in #22114 (the version gate this protects is additive; the OOB path
exists today via `named_data()`/`segments()`).
### Test plan
New `FlatTensorDataMapTest.RejectsOutOfBoundsRootOffset`: copies a valid
PTD into a max-aligned buffer, overwrites only the root offset with a
value past the end (leaving identifier + alignment intact), and asserts
`load()` returns `InvalidExternalData`. Without the check, that same
input is an out-of-bounds vtable read under ASan.
> Note: I was unable to build/run the C++ suite in my local environment,
so I'm relying on this PR's CI (which builds the runtime and runs
`extension/flat_tensor` tests, including under ASan) as the
authoritative check. The change mirrors an existing, tested guard in
`program.cpp`.
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA SignedThis label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed.release notes: runtimeChanges related to the core runtime which loads the program methods, initializes delegates, and runs

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants

@shoumikhin@Gasoonjia@huydhn
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Refuse a PTE or PTD file whose schema version is newer than the runtime can read - #22114

Merged
shoumikhin merged 8 commits into
mainfrom
shoumikhin/program-version-gate
Aug 25, 2026
Merged

Refuse a PTE or PTD file whose schema version is newer than the runtime can read#22114
shoumikhin merged 8 commits into
mainfrom
shoumikhin/program-version-gate

Conversation

@shoumikhin

@shoumikhinshoumikhin commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

Summary

Every exported PTE file and every PTD data file carries a schema version. The exporters stamp it, and nothing in the runtime ever read it back. The only gate on a file was its four byte identifier, ET12 for a program and FT01 for data. That says "this is an ExecuTorch file" and says nothing about which shape of file it is. A file written by an exporter newer than the runtime was therefore accepted, then misread field by field, and failed later at load or during execution with an error that points somewhere else.

This matters more now that the two halves can come from different builds. The wheel ships prebuilt runtime libraries, so a user can export with one installation of ExecuTorch and run with a runtime that was never built next to it.

This PR reads the version in both readers, right after the root table is obtained, and refuses anything above the highest version the runtime supports. Program::load returns InvalidProgram, FlatTensorDataMap::load returns InvalidExternalData, which is what the checks around each of them return. Both name the two numbers:

Program schema version 1 is newer than the highest this runtime supports (0).
Export the model with an older ExecuTorch, or update the runtime.

The comparison is "less than or equal", not "equal", because the project promises that an older file keeps working on a newer runtime, and the schemas only ever grow by appending optional fields. Only a file from the future is refused.

Each new constant sits next to the class it guards, and points in a comment at the writer constants for the same number: EXECUTORCH_SCHEMA_VERSION for the program, kSchemaVersion and _FLAT_TENSOR_VERSION for the data file. A bump of one without the other is then easy to spot. Nothing writes a version other than zero today, so no file in the wild changes behavior. This is the reader side that a future version bump needs in order to mean anything.

One limit worth stating plainly: a runtime that is already deployed has no check at all, so this protects runtimes built from this change onward. That is inherent to adding a reader.

Test plan

Four new tests, two per format. Each pair builds a minimal file in memory through the real FlatBuffer builder, one stamped at the supported version and one above it. The first is expected to load and the second to be refused. The positive test is the control: without it, a rejection could just as well come from a malformed fixture as from the version. The program test asks for the cheapest verification level, which shows the check does not depend on full verification being enabled. The data test follows the byte layout that save_ptd() writes, with no segments.

Built and ran both suites on Linux x86_64, before and after the change:

cmake -S . -B cmake-out -DEXECUTORCH_BUILD_TESTS=ON \
-DEXECUTORCH_ENABLE_PROGRAM_VERIFICATION=ON \
-DEXECUTORCH_BUILD_EXTENSION_DATA_LOADER=ON \
-DEXECUTORCH_BUILD_EXTENSION_FLAT_TENSOR=ON
cmake --build cmake-out -j
./cmake-out/runtime/executor/test/program_test
./cmake-out/extension/flat_tensor/test/extension_flat_tensor_test

The existing tests pass unchanged in both runs, plus the four new ones. As a control, deleting either check while keeping the tests makes that format's negative test fail, with the newer file loading and reporting no error, so the check is what catches it.

Binary size, measured on the two object files. program.cpp.o grows by 24 bytes with logging disabled and 239 bytes with logging enabled. flat_tensor_data_map.cpp.o grows by 32 bytes with logging disabled and 243 bytes with logging enabled.

…read
Every exported PTE file carries a schema version. The exporter stamps it from
EXECUTORCH_SCHEMA_VERSION, and nothing in the runtime ever read it back. The only gate
on a file was the four byte identifier ET12, which says "this is an ExecuTorch program"
and says nothing about which shape of program it is. A file written by an exporter newer
than the runtime was therefore accepted, then misread field by field, and failed later
at load or during execution with an error that points somewhere else.
This matters more now that the two halves can come from different builds. The wheel
ships prebuilt runtime libraries, so a user can export with one installation of
ExecuTorch and run with a runtime that was never built next to it.
Read the version in Program::load, right after the root table is obtained, and refuse
anything above Program::kMaxSupportedSchemaVersion with InvalidProgram and a message
that names both numbers. The comparison is "less than or equal", not "equal", because
the project promises that an older file keeps working on a newer runtime, and the schema
only ever grows by appending optional fields. Only a file from the future is refused.
The new constant sits next to the class it guards, and the two constants point at each
other in comments so that a bump of one without the other is easy to spot. Nothing
writes a version other than zero today, so no file in the wild changes behavior. This is
the reader side that a future version bump needs in order to mean anything.
Two new tests build a minimal program through the real FlatBuffer builder, one stamped
at the supported version and one above it, and check that the first loads and the second
returns InvalidProgram at the cheapest verification level. Ran the program test suite on
Linux x86_64 before and after the change: the same tests pass in both, plus the two new
ones. Deleting the check and keeping the tests makes the negative test fail, with the
newer file loading successfully, so the check is what catches it. Measured on the object
file, the cost is 24 bytes with logging disabled and 239 bytes with logging enabled.
The companion PTD file has the same unread version field, and this change does not touch
it.
CopilotAI lite review requested due to automatic review settings August 24, 2026 23:14
@pytorch-bot

pytorch-botBot commented Aug 24, 2026

Copy link
Copy Markdown

🔗 Helpful Links

🧪 See artifacts and rendered test results at hud.pytorch.org/pr/pytorch/executorch/22114

Note: Links to docs will display an error until the docs builds have been completed.

❌ 1 New Failure, 2 Unclassified Failures

As of commit 8f613a8 with merge base 1afd07f (image):

NEW FAILURE - The following job has failed:

UNCLASSIFIED FAILURES - DrCI could not classify the following jobs because the workflow did not run on the merge base. The failures may be pre-existing on trunk or introduced by this PR:

This comment was automatically generated by Dr. CI and updates every 15 minutes.

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@meta-clameta-claBot added the CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. label Aug 24, 2026
@shoumikhinshoumikhin added the release notes: runtime Changes related to the core runtime which loads the program methods, initializes delegates, and runs label Aug 24, 2026
CopilotAI review requested due to automatic review settings August 24, 2026 23:19

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

…read
The data file has the same hole the program file had. Both writers, the C++ one in
serialize.h and the Python one in serialize.py, stamp a schema version into every PTD
file, and nothing in the runtime ever read it back. The four byte identifier FT01 was the
only gate, and it says nothing about which shape of data follows.
Read the version in FlatTensorDataMap::load, right after the root table is obtained, and
refuse anything above FlatTensorDataMap::kMaxSupportedSchemaVersion with
InvalidExternalData, which is what the checks around it return. The rule is the same as
for the program file: "less than or equal", so an older data file keeps loading.
Two new tests build a minimal PTD file in memory, one stamped at the supported version
and one above it. The positive one is the control. Without it, a rejection could just as
well come from a malformed fixture as from the version. Ran the data map suite on Linux
x86_64: the six existing tests pass unchanged and both new ones pass. Deleting the check
makes the negative test load the newer file with no error at all, so the check is what
catches it. Measured on the object file, the cost is 32 bytes with logging disabled and
243 bytes with logging enabled.
CopilotAI review requested due to automatic review settings August 24, 2026 23:30

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@shoumikhinshoumikhin changed the title Refuse a PTE file whose schema version is newer than the runtime can readRefuse a PTE or PTD file whose schema version is newer than the runtime can readAug 24, 2026
The comment next to each version check said the four byte identifier "only
says that this is a program" / "FlatTensor data". That undersells it. The
identifier is itself the coarse compatibility gate: ET12 and FT01 are bumped
only on a backward-incompatible schema change, so the identifier selects a
schema family and check_header already rejects a file from a different family
as IncompatibleVersion. The new version scalar is the finer gate within one
family. Reword both comments to say that, so a future reader does not conclude
the identifier carries no version meaning. Comment-only, no behavior change.
CopilotAI review requested due to automatic review settings August 25, 2026 00:29

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@GasoonjiaGasoonjia left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for doing the BC/FC check! I think there're two questions im not sure clear:

  1. When should we update the ExecuTorch version? What's the rule behind it?
  2. Is there any ci to mandatory make the version bump happen?

@Gasoonjia

Copy link
Copy Markdown
Contributor

@claude review the PR

@claude

claudeBot commented Aug 25, 2026

Copy link
Copy Markdown

Claude finished @Gasoonjia's task in 2m 7s —— View job


PR Review: Refuse a PTE/PTD file whose schema version is newer than the runtime can read

I read the full diff plus the surrounding load paths in program.cpp and flat_tensor_data_map.cpp. This is a clean, well-scoped change and the reasoning in the description is sound. Below is my feedback, followed by answers to @Gasoonjia's two questions.

What's good

  • Both checks sit in the right place — immediately after the root table pointer is obtained (program.cpp:234, flat_tensor_data_map.cpp:301) and return the same error the surrounding code already uses (InvalidProgram / InvalidExternalData).
  • <= rather than == is the correct choice given the append-only-optional-fields FC promise, and the error messages name both numbers and tell the user what to do.
  • Because version() defaults to 0 when the field is absent, files written before the field existed still load. And since nothing stamps a non-zero version today, this is inert in the wild — low risk.
  • The positive/negative test pairing is the right shape; the program negative test using Verification::Minimal nicely proves the gate doesn't depend on full verification.

The real gap — nothing enforces the constants stay in sync

This is the same concern behind @Gasoonjia's questions. The correctness of the gate depends on four hand-maintained constants agreeing:

ConstantFile
EXECUTORCH_SCHEMA_VERSIONexir/version.py
Program::kMaxSupportedSchemaVersionruntime/executor/program.h:85
_FLAT_TENSOR_VERSIONextension/flat_tensor/serialize/serialize.py:48
kSchemaVersionextension/flat_tensor/serialize/serialize.h
FlatTensorDataMap::kMaxSupportedSchemaVersionextension/flat_tensor/flat_tensor_data_map.h

Right now the only thing tying them together is the cross-reference comments. If someone bumps the writer (exir/version.py) and forgets the reader (program.h), the failure mode is exactly the silent misread this PR is trying to prevent — the reader would reject files it can and should read (an FC regression), and the comments alone won't catch it in review.

Consider adding a tiny guard so a bump can't drift:

  • A C++ static_assert(Program::kMaxSupportedSchemaVersion == kSchemaVersion ...) where the headers are both visible, and/or
  • A Python unit test that reads both .h files (or a generated constant) and asserts equality with EXECUTORCH_SCHEMA_VERSION / _FLAT_TENSOR_VERSION.

That would answer question 2 directly and make the comments enforceable rather than advisory.

Minor

  • Reading version() before full verification. In Verification::Minimal mode only root_offset is bounds-checked (program.cpp:216); flatbuffer_program->version() then dereferences the vtable of an otherwise-unverified buffer. This is consistent with the existing code (named_data(), constant_segment() are read the same way right after), so it's not a regression — just noting the version read inherits the same trust assumptions as everything below it. No change needed.
  • Comment claim about the file identifier. The comments state the 4-byte identifier "changes only on a backward-incompatible schema change." That's the intended contract, but I don't see anything in the schema or tooling that enforces the identifier bumps on a breaking change — it's a convention. Fine to keep, just be aware it's an assertion of intent, not a guarantee the code makes.
  • reinterpret_cast<const uint32_t*>(flatbuffer) in the flat_tensor test assumes little-endian + aligned start. Acceptable for a test fixture, and it mirrors save_ptd(), but it's the fragile part of the suite if the header layout ever changes.

Answers to @Gasoonjia

  1. When should the version bump? The schemas are append-only: adding an optional field is backward- and forward-compatible and does not require a bump (that's the whole point of the <= check). A bump is only meaningful when a change makes a new file unreadable-as-old by an old runtime — i.e. a semantic change to an existing field, or a new field the runtime must understand to execute correctly. Purely additive optional fields shouldn't bump; changes that old runtimes would silently misinterpret should. It would be worth stating this rule explicitly in a comment next to EXECUTORCH_SCHEMA_VERSION, since this PR is what gives the number teeth.
  2. Is there CI to mandate the bump? Not that I can find, and this PR doesn't add one. There's no check that a schema (.fbs) change forces a version bump, nor that the writer/reader constants match. The sync-assert / test suggested above covers the second half (constants agreeing); mandating a bump on .fbs changes is harder and probably out of scope here, but worth a follow-up issue.

Overall: correct and low-risk as written. The one thing I'd push for before merge is a mechanical guard that the writer and reader constants can't diverge — otherwise the whole mechanism is one forgotten edit away from silently regressing FC.
• branch shoumikhin/program-version-gate

CopilotAI review requested due to automatic review settings August 25, 2026 01:16

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

The version gate this PR adds is only correct while five hand-maintained
constants agree: the PTE writer (EXECUTORCH_SCHEMA_VERSION) and its runtime
reader (Program::kMaxSupportedSchemaVersion), and the two PTD writers
(_FLAT_TENSOR_VERSION, kSchemaVersion) and their reader
(FlatTensorDataMap::kMaxSupportedSchemaVersion). Until now only cross-reference
comments tied them together, so bumping a writer without its reader would make a
runtime refuse files it should read, and bumping one PTD writer without the
other would let a file be stamped below its real layout and misread.
Extend schema/test/test_schema.py, which already enforces cross-file schema
sync and runs in OSS CI with no build wiring, with a check that parses the five
literals as text and asserts the compatibility relationship: each writer must be
<= its reader ceiling (a reader may support a version before any writer emits
it, but never the reverse), and the two PTD writers must be exactly equal
because they stamp the same field of the same file. The parse keys on file path,
not symbol, since the two reader ceilings share a name, and fails closed if a
constant can no longer be found so a reformat can't silently disable the guard.
Also state the bump rule next to EXECUTORCH_SCHEMA_VERSION and soften the
file-identifier comments: the identifier is bumped by convention on a breaking
change, not by anything the code enforces.
CopilotAI review requested due to automatic review settings August 25, 2026 01:22

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@shoumikhin

Copy link
Copy Markdown
ContributorAuthor

Thanks @claude — good catch on the drift risk. I added the guard, and it's the right call to do it in this PR since this is what turns the constants into a real gate.

What I added: a check in schema/test/test_schema.py (the file that already enforces cross-file schema sync and runs in OSS CI, so no new build wiring). It reads all five version constants as text and asserts:

  • PTE: EXECUTORCH_SCHEMA_VERSIONProgram::kMaxSupportedSchemaVersion
  • PTD: _FLAT_TENSOR_VERSION == kSchemaVersionFlatTensorDataMap::kMaxSupportedSchemaVersion

The relationship is writer-≤-reader, not strict equality: a runtime is allowed to support a version before any writer emits it (forward-compat), but a writer must never stamp a version its reader would refuse. The two PTD writers are compared with == because they stamp the same field of the same file. The parse keys on the file path (the two reader ceilings share the name kMaxSupportedSchemaVersion) and fails if a constant can't be found, so a rename/reformat can't silently disable the check.

I verified it locally: passes today, fails when I bump one writer without its reader, fails when the two PTD writers disagree, and still passes when only a reader is bumped ahead (the legitimate case).

On the minors:

  • Softened both file-identifier comments to say the tag is bumped by convention on a breaking change — you're right it's not code-enforced.
  • Added the bump rule you described next to EXECUTORCH_SCHEMA_VERSION.
  • Left the version()-before-verification read and the test's reinterpret_cast as-is, since you flagged both as no-change-needed (the version read inherits the same trust as the reads around it; the cast mirrors save_ptd()).

On mandating a bump when a .fbs changes: still out of scope here (it needs schema-shape diffing), but the constant-sync half is now covered. Happy to file a follow-up for the .fbs-change mandate and for the Python PTD reader still using != (separate small PR).

@shoumikhin

Copy link
Copy Markdown
ContributorAuthor

@claude review the PR

@claude

claudeBot commented Aug 25, 2026

Copy link
Copy Markdown

Claude finished @shoumikhin's task in 1m 32s —— View job


Re: the drift guard (8f613a8)

I read the new test_schema.py::test_schema_version_constants_in_sync and traced all five regexes against the actual declarations. This resolves the concern from my earlier review — the guard is correct and lands in the right place.

Verified the five patterns match today's source:

ConstantDeclarationRegex
EXECUTORCH_SCHEMA_VERSION= 0 (exir/version.py:14)
Program::kMaxSupportedSchemaVersion= 0 (program.h:85)
_FLAT_TENSOR_VERSION: int = 0 (serialize.py:49)
kSchemaVersion= 0 (serialize.h:26)
FlatTensorDataMap::kMaxSupportedSchemaVersion= 0 (flat_tensor_data_map.h:47)

The design decisions are right:

  • Keying on file path, not symbol name is the correct call — the two reader ceilings both spell kMaxSupportedSchemaVersion, so a symbol-based parse would be ambiguous. Path-keyed re.search on each file avoids that, and since kMaxSupportedSchemaVersion doesn't contain the literal substring kSchemaVersion, the PTD-writer regex in serialize.h can't accidentally match a ceiling either.
  • Writer <= reader, PTD writers == matches the compatibility policy exactly: a runtime may support a version before any writer emits it (FC), but a writer must never stamp a version its reader refuses; and the two PTD writers stamp the same field, so they must be equal. The asymmetry is intentional and correctly encoded.
  • Fail-closed on a missing constant (assertIsNotNone with a message that tells the next editor to fix the test) is what makes a rename/reformat surface loudly instead of silently disabling the guard — this was the exact failure mode I was worried about.
  • Putting it in test_schema.py (already in OSS CI, no new build wiring) is the pragmatic choice over a static_assert, which couldn't reach the Python writers anyway.

On the minors — all handled as expected: the file-identifier comments now say "by convention," the bump rule is stated next to EXECUTORCH_SCHEMA_VERSION, and leaving the version()-before-verification read and the test reinterpret_cast as-is is fine.

One small note (non-blocking): the guard proves the five constants agree with each other, but the writer/reader ceiling and the actual serialized field are still tied only by the runtime code that reads version(). That's fine — that path is what your four new C++ tests exercise, so the two halves (constants-agree here, field-is-gated there) are each covered.

On follow-ups: filing separate issues for (a) mandating a version bump on .fbs shape changes and (b) the Python PTD reader still using != instead of <= sounds right — both are genuinely out of scope for this PR, and the constant-sync half was the load-bearing gap.

I couldn't run the suite here (sandbox blocked python), but the parse is simple text matching and I verified each pattern against its literal by hand; your local run (passes today, fails on each induced drift) covers the dynamic behavior.

LGTM.
• branch shoumikhin/program-version-gate

@claude

claudeBot commented Aug 25, 2026

Copy link
Copy Markdown

Claude finished @shoumikhin's task in 2m 4s —— View job


Re-review: Refuse a PTE/PTD file whose schema version is newer than the runtime can read

I re-read the full diff against origin/main, focused on the commit added since my last pass (the constant-sync guard) plus a fresh look at the readers and tests. My earlier concern — that the whole mechanism was one forgotten edit away from silently regressing FC — is now addressed. Summary: correct, low-risk, and I'd merge it.

The drift guard resolves the main gap

schema/test/test_schema.py::test_schema_version_constants_in_sync is the right fix in the right place — it lives in the file that already enforces cross-file schema sync and runs in OSS CI, so no new build wiring. Reviewing the logic:

  • Text-parse over import is the correct call here (importing the writers drags in torch; the C++ constants have no Python binding), and keying the parse on file path rather than symbol name correctly disambiguates the two reader ceilings that share kMaxSupportedSchemaVersion (program.h:80, flat_tensor_data_map.h:46).
  • The assertIsNotNone on every match means a rename or reformat fails the test loudly instead of silently disabling the check — exactly the failure mode you'd want to avoid in a guard like this.
  • The relations model the compatibility policy correctly: writer--reader for each family (a runtime may support a version before any writer emits it), and == between the two PTD writers (they stamp the same field of the same file). PTE and PTD kept independent.

Readers — verified placement

  • Program::load (program.cpp:226) — the check sits right after the root-table pointer and before named_data/constant_segment reads, returning InvalidProgram. Correct.
  • FlatTensorDataMap (flat_tensor_data_map.cpp:301) — same shape, returns InvalidExternalData. Correct.
  • <= (not ==), version defaulting to 0 when absent, and both numbers named in the message with an actionable fix — all as described.

Tests

  • The positive/negative pairing per format is the right shape, and the program negative test using Verification::Minimal proves the gate doesn't depend on full verification. The negative fixtures are otherwise-valid files differing only in the version field, so the rejection is attributable to the gate and nothing else.
  • The flat_tensor fixture's reinterpret_cast<const uint32_t*>(flatbuffer) assumes little-endian + aligned start — fine for a test fixture and it mirrors save_ptd(), as previously noted. No change needed.

Minors addressed

  • Both file-identifier comments now say the tag is bumped by convention — matches reality (the identifier isn't code-enforced to bump on a break).
  • The bump rule is now documented next to EXECUTORCH_SCHEMA_VERSION (exir/version.py), which is what gives the number teeth.

One optional follow-up (non-blocking)

As you noted, the guard covers the constants agreeing half. It does not mandate a version bump when a .fbs actually changes shape — that needs schema-shape diffing and is reasonably out of scope. Worth a follow-up issue, along with the Python PTD reader still comparing with != (serialize.py) rather than the <= semantics this PR establishes on the C++ side, so a future non-zero PTD version wouldn't regress the Python path.

Nothing here blocks merge. Nice work turning the cross-reference comments into an enforced check within this same PR.
• branch shoumikhin/program-version-gate

<cstring> and <vector> already arrive through buffer_data_loader.h and the
generated flatbuffers header, so drop them. Replace the per-symbol using
declarations with namespace directives, matching the convention used elsewhere.
CopilotAI review requested due to automatic review settings August 25, 2026 02:07

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@shoumikhin
shoumikhin merged commit d181029 into mainAug 25, 2026
212 of 213 checks passed
@shoumikhin
shoumikhin deleted the shoumikhin/program-version-gate branch August 25, 2026 02:44
shoumikhin added a commit that referenced this pull request Aug 25, 2026
…22117)
### Summary
The Python `FlatTensorSerializer.deserialize` refused any PTD file whose
`version` did not **exactly** equal `_FLAT_TENSOR_VERSION`:
```python
if flat_tensor.version != _FLAT_TENSOR_VERSION:
raise NotImplementedError(...)
```
That rejects an **older** file too, which contradicts:
- the append-only schema policy in `schema/README.md` (older files stay
loadable), and
- the C++ runtime readers, which accept anything `<=` their supported
version and only refuse a file **newer** than they understand
(`Program::load`, `FlatTensorDataMap::load`).
This aligns the Python PTD reader with that policy: compare with `>`
instead of `!=`, so an older or equal file loads and only a newer one is
refused. The error message now says the file is newer than this reader
supports.
### Context
This was called out as a follow-up during review of #22114 (which adds
the `<=` gate on the C++ side). It keeps the Python export/tooling path
consistent so a future non-zero PTD version won't regress it.
### Test plan
Two tests in `extension/flat_tensor/test/test_serialize.py`:
- `test_deserialize_refuses_newer_version` — a bumped-version file is
refused.
- `test_deserialize_accepts_older_version` — a file older than the
reader still loads (the case the old `!=` wrongly rejected).
Both are behavior-only; no schema or format change. Version constants
are unchanged (still `0`), so this is inert for existing files and only
changes behavior once a non-zero version is ever stamped.
shoumikhin added a commit that referenced this pull request Aug 25, 2026
### Summary
`FlatTensorDataMap::load()` reads the flatbuffer root table without
first bounds-checking the root offset:
```cpp
const flat_tensor_flatbuffer::FlatTensor* flat_tensor =
flat_tensor_flatbuffer::GetFlatTensor(flat_tensor_data->data());
// then immediately:
flat_tensor->named_data(); // walks the root vtable
flat_tensor->segments();
```
`GetFlatTensor()` interprets the `uoffset_t` at the start of the buffer
as the root-table offset and returns `buf + offset`. The first field
access then walks that table's vtable. Nothing on this path runs full
flatbuffer verification, so a corrupt or truncated PTD file with a bad
root offset makes these accessors dereference memory **outside the
buffer** (an OOB read, flagged by ASan).
The program loader already guards against exactly this in
`runtime/executor/program.cpp`. This change mirrors that guard for PTD
files.
### What changed
After the existing identifier and alignment checks, before
`GetFlatTensor()`:
- Confirm the buffer is at least a flatbuffer header long (`uoffset_t` +
file identifier).
- Confirm the root offset is `>= kMinBufferSize` and leaves room for a
vtable `soffset_t` within the buffer.
- Return `InvalidExternalData` otherwise (the same error the surrounding
checks use).
FlatTensor is **not** size-prefixed — both writers finish the buffer
plain (`builder.Finish` in C++, default `flatc` in Python) and the
reader uses `GetFlatTensor` (not the size-prefixed variant) — so the
root offset is at byte 0, exactly as in the program case.
### Scope
This is a pre-existing hardening, independent of the schema-version work
in #22114 (the version gate this protects is additive; the OOB path
exists today via `named_data()`/`segments()`).
### Test plan
New `FlatTensorDataMapTest.RejectsOutOfBoundsRootOffset`: copies a valid
PTD into a max-aligned buffer, overwrites only the root offset with a
value past the end (leaving identifier + alignment intact), and asserts
`load()` returns `InvalidExternalData`. Without the check, that same
input is an out-of-bounds vtable read under ASan.
> Note: I was unable to build/run the C++ suite in my local environment,
so I'm relying on this PR's CI (which builds the runtime and runs
`extension/flat_tensor` tests, including under ASan) as the
authoritative check. The change mirrors an existing, tested guard in
`program.cpp`.
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA SignedThis label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed.release notes: runtimeChanges related to the core runtime which loads the program methods, initializes delegates, and runs

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants

@shoumikhin@Gasoonjia@huydhn
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

Refuse a PTE or PTD file whose schema version is newer than the runtime can read - #22114

Merged
shoumikhin merged 8 commits into
mainfrom
shoumikhin/program-version-gate
Aug 25, 2026
Merged

Refuse a PTE or PTD file whose schema version is newer than the runtime can read#22114
shoumikhin merged 8 commits into
mainfrom
shoumikhin/program-version-gate

Conversation

@shoumikhin

@shoumikhinshoumikhin commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

Summary

Every exported PTE file and every PTD data file carries a schema version. The exporters stamp it, and nothing in the runtime ever read it back. The only gate on a file was its four byte identifier, ET12 for a program and FT01 for data. That says "this is an ExecuTorch file" and says nothing about which shape of file it is. A file written by an exporter newer than the runtime was therefore accepted, then misread field by field, and failed later at load or during execution with an error that points somewhere else.

This matters more now that the two halves can come from different builds. The wheel ships prebuilt runtime libraries, so a user can export with one installation of ExecuTorch and run with a runtime that was never built next to it.

This PR reads the version in both readers, right after the root table is obtained, and refuses anything above the highest version the runtime supports. Program::load returns InvalidProgram, FlatTensorDataMap::load returns InvalidExternalData, which is what the checks around each of them return. Both name the two numbers:

Program schema version 1 is newer than the highest this runtime supports (0).
Export the model with an older ExecuTorch, or update the runtime.

The comparison is "less than or equal", not "equal", because the project promises that an older file keeps working on a newer runtime, and the schemas only ever grow by appending optional fields. Only a file from the future is refused.

Each new constant sits next to the class it guards, and points in a comment at the writer constants for the same number: EXECUTORCH_SCHEMA_VERSION for the program, kSchemaVersion and _FLAT_TENSOR_VERSION for the data file. A bump of one without the other is then easy to spot. Nothing writes a version other than zero today, so no file in the wild changes behavior. This is the reader side that a future version bump needs in order to mean anything.

One limit worth stating plainly: a runtime that is already deployed has no check at all, so this protects runtimes built from this change onward. That is inherent to adding a reader.

Test plan

Four new tests, two per format. Each pair builds a minimal file in memory through the real FlatBuffer builder, one stamped at the supported version and one above it. The first is expected to load and the second to be refused. The positive test is the control: without it, a rejection could just as well come from a malformed fixture as from the version. The program test asks for the cheapest verification level, which shows the check does not depend on full verification being enabled. The data test follows the byte layout that save_ptd() writes, with no segments.

Built and ran both suites on Linux x86_64, before and after the change:

cmake -S . -B cmake-out -DEXECUTORCH_BUILD_TESTS=ON \
-DEXECUTORCH_ENABLE_PROGRAM_VERIFICATION=ON \
-DEXECUTORCH_BUILD_EXTENSION_DATA_LOADER=ON \
-DEXECUTORCH_BUILD_EXTENSION_FLAT_TENSOR=ON
cmake --build cmake-out -j
./cmake-out/runtime/executor/test/program_test
./cmake-out/extension/flat_tensor/test/extension_flat_tensor_test

The existing tests pass unchanged in both runs, plus the four new ones. As a control, deleting either check while keeping the tests makes that format's negative test fail, with the newer file loading and reporting no error, so the check is what catches it.

Binary size, measured on the two object files. program.cpp.o grows by 24 bytes with logging disabled and 239 bytes with logging enabled. flat_tensor_data_map.cpp.o grows by 32 bytes with logging disabled and 243 bytes with logging enabled.

…read
Every exported PTE file carries a schema version. The exporter stamps it from
EXECUTORCH_SCHEMA_VERSION, and nothing in the runtime ever read it back. The only gate
on a file was the four byte identifier ET12, which says "this is an ExecuTorch program"
and says nothing about which shape of program it is. A file written by an exporter newer
than the runtime was therefore accepted, then misread field by field, and failed later
at load or during execution with an error that points somewhere else.
This matters more now that the two halves can come from different builds. The wheel
ships prebuilt runtime libraries, so a user can export with one installation of
ExecuTorch and run with a runtime that was never built next to it.
Read the version in Program::load, right after the root table is obtained, and refuse
anything above Program::kMaxSupportedSchemaVersion with InvalidProgram and a message
that names both numbers. The comparison is "less than or equal", not "equal", because
the project promises that an older file keeps working on a newer runtime, and the schema
only ever grows by appending optional fields. Only a file from the future is refused.
The new constant sits next to the class it guards, and the two constants point at each
other in comments so that a bump of one without the other is easy to spot. Nothing
writes a version other than zero today, so no file in the wild changes behavior. This is
the reader side that a future version bump needs in order to mean anything.
Two new tests build a minimal program through the real FlatBuffer builder, one stamped
at the supported version and one above it, and check that the first loads and the second
returns InvalidProgram at the cheapest verification level. Ran the program test suite on
Linux x86_64 before and after the change: the same tests pass in both, plus the two new
ones. Deleting the check and keeping the tests makes the negative test fail, with the
newer file loading successfully, so the check is what catches it. Measured on the object
file, the cost is 24 bytes with logging disabled and 239 bytes with logging enabled.
The companion PTD file has the same unread version field, and this change does not touch
it.
CopilotAI lite review requested due to automatic review settings August 24, 2026 23:14
@pytorch-bot

pytorch-botBot commented Aug 24, 2026

Copy link
Copy Markdown

🔗 Helpful Links

🧪 See artifacts and rendered test results at hud.pytorch.org/pr/pytorch/executorch/22114

Note: Links to docs will display an error until the docs builds have been completed.

❌ 1 New Failure, 2 Unclassified Failures

As of commit 8f613a8 with merge base 1afd07f (image):

NEW FAILURE - The following job has failed:

UNCLASSIFIED FAILURES - DrCI could not classify the following jobs because the workflow did not run on the merge base. The failures may be pre-existing on trunk or introduced by this PR:

This comment was automatically generated by Dr. CI and updates every 15 minutes.

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@meta-clameta-claBot added the CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. label Aug 24, 2026
@shoumikhinshoumikhin added the release notes: runtime Changes related to the core runtime which loads the program methods, initializes delegates, and runs label Aug 24, 2026
CopilotAI review requested due to automatic review settings August 24, 2026 23:19

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

…read
The data file has the same hole the program file had. Both writers, the C++ one in
serialize.h and the Python one in serialize.py, stamp a schema version into every PTD
file, and nothing in the runtime ever read it back. The four byte identifier FT01 was the
only gate, and it says nothing about which shape of data follows.
Read the version in FlatTensorDataMap::load, right after the root table is obtained, and
refuse anything above FlatTensorDataMap::kMaxSupportedSchemaVersion with
InvalidExternalData, which is what the checks around it return. The rule is the same as
for the program file: "less than or equal", so an older data file keeps loading.
Two new tests build a minimal PTD file in memory, one stamped at the supported version
and one above it. The positive one is the control. Without it, a rejection could just as
well come from a malformed fixture as from the version. Ran the data map suite on Linux
x86_64: the six existing tests pass unchanged and both new ones pass. Deleting the check
makes the negative test load the newer file with no error at all, so the check is what
catches it. Measured on the object file, the cost is 32 bytes with logging disabled and
243 bytes with logging enabled.
CopilotAI review requested due to automatic review settings August 24, 2026 23:30

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@shoumikhinshoumikhin changed the title Refuse a PTE file whose schema version is newer than the runtime can readRefuse a PTE or PTD file whose schema version is newer than the runtime can readAug 24, 2026
The comment next to each version check said the four byte identifier "only
says that this is a program" / "FlatTensor data". That undersells it. The
identifier is itself the coarse compatibility gate: ET12 and FT01 are bumped
only on a backward-incompatible schema change, so the identifier selects a
schema family and check_header already rejects a file from a different family
as IncompatibleVersion. The new version scalar is the finer gate within one
family. Reword both comments to say that, so a future reader does not conclude
the identifier carries no version meaning. Comment-only, no behavior change.
CopilotAI review requested due to automatic review settings August 25, 2026 00:29

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@GasoonjiaGasoonjia left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for doing the BC/FC check! I think there're two questions im not sure clear:

  1. When should we update the ExecuTorch version? What's the rule behind it?
  2. Is there any ci to mandatory make the version bump happen?

@Gasoonjia

Copy link
Copy Markdown
Contributor

@claude review the PR

@claude

claudeBot commented Aug 25, 2026

Copy link
Copy Markdown

Claude finished @Gasoonjia's task in 2m 7s —— View job


PR Review: Refuse a PTE/PTD file whose schema version is newer than the runtime can read

I read the full diff plus the surrounding load paths in program.cpp and flat_tensor_data_map.cpp. This is a clean, well-scoped change and the reasoning in the description is sound. Below is my feedback, followed by answers to @Gasoonjia's two questions.

What's good

  • Both checks sit in the right place — immediately after the root table pointer is obtained (program.cpp:234, flat_tensor_data_map.cpp:301) and return the same error the surrounding code already uses (InvalidProgram / InvalidExternalData).
  • <= rather than == is the correct choice given the append-only-optional-fields FC promise, and the error messages name both numbers and tell the user what to do.
  • Because version() defaults to 0 when the field is absent, files written before the field existed still load. And since nothing stamps a non-zero version today, this is inert in the wild — low risk.
  • The positive/negative test pairing is the right shape; the program negative test using Verification::Minimal nicely proves the gate doesn't depend on full verification.

The real gap — nothing enforces the constants stay in sync

This is the same concern behind @Gasoonjia's questions. The correctness of the gate depends on four hand-maintained constants agreeing:

ConstantFile
EXECUTORCH_SCHEMA_VERSIONexir/version.py
Program::kMaxSupportedSchemaVersionruntime/executor/program.h:85
_FLAT_TENSOR_VERSIONextension/flat_tensor/serialize/serialize.py:48
kSchemaVersionextension/flat_tensor/serialize/serialize.h
FlatTensorDataMap::kMaxSupportedSchemaVersionextension/flat_tensor/flat_tensor_data_map.h

Right now the only thing tying them together is the cross-reference comments. If someone bumps the writer (exir/version.py) and forgets the reader (program.h), the failure mode is exactly the silent misread this PR is trying to prevent — the reader would reject files it can and should read (an FC regression), and the comments alone won't catch it in review.

Consider adding a tiny guard so a bump can't drift:

  • A C++ static_assert(Program::kMaxSupportedSchemaVersion == kSchemaVersion ...) where the headers are both visible, and/or
  • A Python unit test that reads both .h files (or a generated constant) and asserts equality with EXECUTORCH_SCHEMA_VERSION / _FLAT_TENSOR_VERSION.

That would answer question 2 directly and make the comments enforceable rather than advisory.

Minor

  • Reading version() before full verification. In Verification::Minimal mode only root_offset is bounds-checked (program.cpp:216); flatbuffer_program->version() then dereferences the vtable of an otherwise-unverified buffer. This is consistent with the existing code (named_data(), constant_segment() are read the same way right after), so it's not a regression — just noting the version read inherits the same trust assumptions as everything below it. No change needed.
  • Comment claim about the file identifier. The comments state the 4-byte identifier "changes only on a backward-incompatible schema change." That's the intended contract, but I don't see anything in the schema or tooling that enforces the identifier bumps on a breaking change — it's a convention. Fine to keep, just be aware it's an assertion of intent, not a guarantee the code makes.
  • reinterpret_cast<const uint32_t*>(flatbuffer) in the flat_tensor test assumes little-endian + aligned start. Acceptable for a test fixture, and it mirrors save_ptd(), but it's the fragile part of the suite if the header layout ever changes.

Answers to @Gasoonjia

  1. When should the version bump? The schemas are append-only: adding an optional field is backward- and forward-compatible and does not require a bump (that's the whole point of the <= check). A bump is only meaningful when a change makes a new file unreadable-as-old by an old runtime — i.e. a semantic change to an existing field, or a new field the runtime must understand to execute correctly. Purely additive optional fields shouldn't bump; changes that old runtimes would silently misinterpret should. It would be worth stating this rule explicitly in a comment next to EXECUTORCH_SCHEMA_VERSION, since this PR is what gives the number teeth.
  2. Is there CI to mandate the bump? Not that I can find, and this PR doesn't add one. There's no check that a schema (.fbs) change forces a version bump, nor that the writer/reader constants match. The sync-assert / test suggested above covers the second half (constants agreeing); mandating a bump on .fbs changes is harder and probably out of scope here, but worth a follow-up issue.

Overall: correct and low-risk as written. The one thing I'd push for before merge is a mechanical guard that the writer and reader constants can't diverge — otherwise the whole mechanism is one forgotten edit away from silently regressing FC.
• branch shoumikhin/program-version-gate

CopilotAI review requested due to automatic review settings August 25, 2026 01:16

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

The version gate this PR adds is only correct while five hand-maintained
constants agree: the PTE writer (EXECUTORCH_SCHEMA_VERSION) and its runtime
reader (Program::kMaxSupportedSchemaVersion), and the two PTD writers
(_FLAT_TENSOR_VERSION, kSchemaVersion) and their reader
(FlatTensorDataMap::kMaxSupportedSchemaVersion). Until now only cross-reference
comments tied them together, so bumping a writer without its reader would make a
runtime refuse files it should read, and bumping one PTD writer without the
other would let a file be stamped below its real layout and misread.
Extend schema/test/test_schema.py, which already enforces cross-file schema
sync and runs in OSS CI with no build wiring, with a check that parses the five
literals as text and asserts the compatibility relationship: each writer must be
<= its reader ceiling (a reader may support a version before any writer emits
it, but never the reverse), and the two PTD writers must be exactly equal
because they stamp the same field of the same file. The parse keys on file path,
not symbol, since the two reader ceilings share a name, and fails closed if a
constant can no longer be found so a reformat can't silently disable the guard.
Also state the bump rule next to EXECUTORCH_SCHEMA_VERSION and soften the
file-identifier comments: the identifier is bumped by convention on a breaking
change, not by anything the code enforces.
CopilotAI review requested due to automatic review settings August 25, 2026 01:22

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@shoumikhin

Copy link
Copy Markdown
ContributorAuthor

Thanks @claude — good catch on the drift risk. I added the guard, and it's the right call to do it in this PR since this is what turns the constants into a real gate.

What I added: a check in schema/test/test_schema.py (the file that already enforces cross-file schema sync and runs in OSS CI, so no new build wiring). It reads all five version constants as text and asserts:

  • PTE: EXECUTORCH_SCHEMA_VERSIONProgram::kMaxSupportedSchemaVersion
  • PTD: _FLAT_TENSOR_VERSION == kSchemaVersionFlatTensorDataMap::kMaxSupportedSchemaVersion

The relationship is writer-≤-reader, not strict equality: a runtime is allowed to support a version before any writer emits it (forward-compat), but a writer must never stamp a version its reader would refuse. The two PTD writers are compared with == because they stamp the same field of the same file. The parse keys on the file path (the two reader ceilings share the name kMaxSupportedSchemaVersion) and fails if a constant can't be found, so a rename/reformat can't silently disable the check.

I verified it locally: passes today, fails when I bump one writer without its reader, fails when the two PTD writers disagree, and still passes when only a reader is bumped ahead (the legitimate case).

On the minors:

  • Softened both file-identifier comments to say the tag is bumped by convention on a breaking change — you're right it's not code-enforced.
  • Added the bump rule you described next to EXECUTORCH_SCHEMA_VERSION.
  • Left the version()-before-verification read and the test's reinterpret_cast as-is, since you flagged both as no-change-needed (the version read inherits the same trust as the reads around it; the cast mirrors save_ptd()).

On mandating a bump when a .fbs changes: still out of scope here (it needs schema-shape diffing), but the constant-sync half is now covered. Happy to file a follow-up for the .fbs-change mandate and for the Python PTD reader still using != (separate small PR).

@shoumikhin

Copy link
Copy Markdown
ContributorAuthor

@claude review the PR

@claude

claudeBot commented Aug 25, 2026

Copy link
Copy Markdown

Claude finished @shoumikhin's task in 1m 32s —— View job


Re: the drift guard (8f613a8)

I read the new test_schema.py::test_schema_version_constants_in_sync and traced all five regexes against the actual declarations. This resolves the concern from my earlier review — the guard is correct and lands in the right place.

Verified the five patterns match today's source:

ConstantDeclarationRegex
EXECUTORCH_SCHEMA_VERSION= 0 (exir/version.py:14)
Program::kMaxSupportedSchemaVersion= 0 (program.h:85)
_FLAT_TENSOR_VERSION: int = 0 (serialize.py:49)
kSchemaVersion= 0 (serialize.h:26)
FlatTensorDataMap::kMaxSupportedSchemaVersion= 0 (flat_tensor_data_map.h:47)

The design decisions are right:

  • Keying on file path, not symbol name is the correct call — the two reader ceilings both spell kMaxSupportedSchemaVersion, so a symbol-based parse would be ambiguous. Path-keyed re.search on each file avoids that, and since kMaxSupportedSchemaVersion doesn't contain the literal substring kSchemaVersion, the PTD-writer regex in serialize.h can't accidentally match a ceiling either.
  • Writer <= reader, PTD writers == matches the compatibility policy exactly: a runtime may support a version before any writer emits it (FC), but a writer must never stamp a version its reader refuses; and the two PTD writers stamp the same field, so they must be equal. The asymmetry is intentional and correctly encoded.
  • Fail-closed on a missing constant (assertIsNotNone with a message that tells the next editor to fix the test) is what makes a rename/reformat surface loudly instead of silently disabling the guard — this was the exact failure mode I was worried about.
  • Putting it in test_schema.py (already in OSS CI, no new build wiring) is the pragmatic choice over a static_assert, which couldn't reach the Python writers anyway.

On the minors — all handled as expected: the file-identifier comments now say "by convention," the bump rule is stated next to EXECUTORCH_SCHEMA_VERSION, and leaving the version()-before-verification read and the test reinterpret_cast as-is is fine.

One small note (non-blocking): the guard proves the five constants agree with each other, but the writer/reader ceiling and the actual serialized field are still tied only by the runtime code that reads version(). That's fine — that path is what your four new C++ tests exercise, so the two halves (constants-agree here, field-is-gated there) are each covered.

On follow-ups: filing separate issues for (a) mandating a version bump on .fbs shape changes and (b) the Python PTD reader still using != instead of <= sounds right — both are genuinely out of scope for this PR, and the constant-sync half was the load-bearing gap.

I couldn't run the suite here (sandbox blocked python), but the parse is simple text matching and I verified each pattern against its literal by hand; your local run (passes today, fails on each induced drift) covers the dynamic behavior.

LGTM.
• branch shoumikhin/program-version-gate

@claude

claudeBot commented Aug 25, 2026

Copy link
Copy Markdown

Claude finished @shoumikhin's task in 2m 4s —— View job


Re-review: Refuse a PTE/PTD file whose schema version is newer than the runtime can read

I re-read the full diff against origin/main, focused on the commit added since my last pass (the constant-sync guard) plus a fresh look at the readers and tests. My earlier concern — that the whole mechanism was one forgotten edit away from silently regressing FC — is now addressed. Summary: correct, low-risk, and I'd merge it.

The drift guard resolves the main gap

schema/test/test_schema.py::test_schema_version_constants_in_sync is the right fix in the right place — it lives in the file that already enforces cross-file schema sync and runs in OSS CI, so no new build wiring. Reviewing the logic:

  • Text-parse over import is the correct call here (importing the writers drags in torch; the C++ constants have no Python binding), and keying the parse on file path rather than symbol name correctly disambiguates the two reader ceilings that share kMaxSupportedSchemaVersion (program.h:80, flat_tensor_data_map.h:46).
  • The assertIsNotNone on every match means a rename or reformat fails the test loudly instead of silently disabling the check — exactly the failure mode you'd want to avoid in a guard like this.
  • The relations model the compatibility policy correctly: writer--reader for each family (a runtime may support a version before any writer emits it), and == between the two PTD writers (they stamp the same field of the same file). PTE and PTD kept independent.

Readers — verified placement

  • Program::load (program.cpp:226) — the check sits right after the root-table pointer and before named_data/constant_segment reads, returning InvalidProgram. Correct.
  • FlatTensorDataMap (flat_tensor_data_map.cpp:301) — same shape, returns InvalidExternalData. Correct.
  • <= (not ==), version defaulting to 0 when absent, and both numbers named in the message with an actionable fix — all as described.

Tests

  • The positive/negative pairing per format is the right shape, and the program negative test using Verification::Minimal proves the gate doesn't depend on full verification. The negative fixtures are otherwise-valid files differing only in the version field, so the rejection is attributable to the gate and nothing else.
  • The flat_tensor fixture's reinterpret_cast<const uint32_t*>(flatbuffer) assumes little-endian + aligned start — fine for a test fixture and it mirrors save_ptd(), as previously noted. No change needed.

Minors addressed

  • Both file-identifier comments now say the tag is bumped by convention — matches reality (the identifier isn't code-enforced to bump on a break).
  • The bump rule is now documented next to EXECUTORCH_SCHEMA_VERSION (exir/version.py), which is what gives the number teeth.

One optional follow-up (non-blocking)

As you noted, the guard covers the constants agreeing half. It does not mandate a version bump when a .fbs actually changes shape — that needs schema-shape diffing and is reasonably out of scope. Worth a follow-up issue, along with the Python PTD reader still comparing with != (serialize.py) rather than the <= semantics this PR establishes on the C++ side, so a future non-zero PTD version wouldn't regress the Python path.

Nothing here blocks merge. Nice work turning the cross-reference comments into an enforced check within this same PR.
• branch shoumikhin/program-version-gate

<cstring> and <vector> already arrive through buffer_data_loader.h and the
generated flatbuffers header, so drop them. Replace the per-symbol using
declarations with namespace directives, matching the convention used elsewhere.
CopilotAI review requested due to automatic review settings August 25, 2026 02:07

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@shoumikhin
shoumikhin merged commit d181029 into mainAug 25, 2026
212 of 213 checks passed
@shoumikhin
shoumikhin deleted the shoumikhin/program-version-gate branch August 25, 2026 02:44
shoumikhin added a commit that referenced this pull request Aug 25, 2026
…22117)
### Summary
The Python `FlatTensorSerializer.deserialize` refused any PTD file whose
`version` did not **exactly** equal `_FLAT_TENSOR_VERSION`:
```python
if flat_tensor.version != _FLAT_TENSOR_VERSION:
raise NotImplementedError(...)
```
That rejects an **older** file too, which contradicts:
- the append-only schema policy in `schema/README.md` (older files stay
loadable), and
- the C++ runtime readers, which accept anything `<=` their supported
version and only refuse a file **newer** than they understand
(`Program::load`, `FlatTensorDataMap::load`).
This aligns the Python PTD reader with that policy: compare with `>`
instead of `!=`, so an older or equal file loads and only a newer one is
refused. The error message now says the file is newer than this reader
supports.
### Context
This was called out as a follow-up during review of #22114 (which adds
the `<=` gate on the C++ side). It keeps the Python export/tooling path
consistent so a future non-zero PTD version won't regress it.
### Test plan
Two tests in `extension/flat_tensor/test/test_serialize.py`:
- `test_deserialize_refuses_newer_version` — a bumped-version file is
refused.
- `test_deserialize_accepts_older_version` — a file older than the
reader still loads (the case the old `!=` wrongly rejected).
Both are behavior-only; no schema or format change. Version constants
are unchanged (still `0`), so this is inert for existing files and only
changes behavior once a non-zero version is ever stamped.
shoumikhin added a commit that referenced this pull request Aug 25, 2026
### Summary
`FlatTensorDataMap::load()` reads the flatbuffer root table without
first bounds-checking the root offset:
```cpp
const flat_tensor_flatbuffer::FlatTensor* flat_tensor =
flat_tensor_flatbuffer::GetFlatTensor(flat_tensor_data->data());
// then immediately:
flat_tensor->named_data(); // walks the root vtable
flat_tensor->segments();
```
`GetFlatTensor()` interprets the `uoffset_t` at the start of the buffer
as the root-table offset and returns `buf + offset`. The first field
access then walks that table's vtable. Nothing on this path runs full
flatbuffer verification, so a corrupt or truncated PTD file with a bad
root offset makes these accessors dereference memory **outside the
buffer** (an OOB read, flagged by ASan).
The program loader already guards against exactly this in
`runtime/executor/program.cpp`. This change mirrors that guard for PTD
files.
### What changed
After the existing identifier and alignment checks, before
`GetFlatTensor()`:
- Confirm the buffer is at least a flatbuffer header long (`uoffset_t` +
file identifier).
- Confirm the root offset is `>= kMinBufferSize` and leaves room for a
vtable `soffset_t` within the buffer.
- Return `InvalidExternalData` otherwise (the same error the surrounding
checks use).
FlatTensor is **not** size-prefixed — both writers finish the buffer
plain (`builder.Finish` in C++, default `flatc` in Python) and the
reader uses `GetFlatTensor` (not the size-prefixed variant) — so the
root offset is at byte 0, exactly as in the program case.
### Scope
This is a pre-existing hardening, independent of the schema-version work
in #22114 (the version gate this protects is additive; the OOB path
exists today via `named_data()`/`segments()`).
### Test plan
New `FlatTensorDataMapTest.RejectsOutOfBoundsRootOffset`: copies a valid
PTD into a max-aligned buffer, overwrites only the root offset with a
value past the end (leaving identifier + alignment intact), and asserts
`load()` returns `InvalidExternalData`. Without the check, that same
input is an out-of-bounds vtable read under ASan.
> Note: I was unable to build/run the C++ suite in my local environment,
so I'm relying on this PR's CI (which builds the runtime and runs
`extension/flat_tensor` tests, including under ASan) as the
authoritative check. The change mirrors an existing, tested guard in
`program.cpp`.
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA SignedThis label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed.release notes: runtimeChanges related to the core runtime which loads the program methods, initializes delegates, and runs

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants

@shoumikhin@Gasoonjia@huydhn
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Refuse a PTE or PTD file whose schema version is newer than the runtime can read - #22114

Merged
shoumikhin merged 8 commits into
mainfrom
shoumikhin/program-version-gate
Aug 25, 2026
Merged

Refuse a PTE or PTD file whose schema version is newer than the runtime can read#22114
shoumikhin merged 8 commits into
mainfrom
shoumikhin/program-version-gate

Conversation

@shoumikhin

@shoumikhinshoumikhin commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

Summary

Every exported PTE file and every PTD data file carries a schema version. The exporters stamp it, and nothing in the runtime ever read it back. The only gate on a file was its four byte identifier, ET12 for a program and FT01 for data. That says "this is an ExecuTorch file" and says nothing about which shape of file it is. A file written by an exporter newer than the runtime was therefore accepted, then misread field by field, and failed later at load or during execution with an error that points somewhere else.

This matters more now that the two halves can come from different builds. The wheel ships prebuilt runtime libraries, so a user can export with one installation of ExecuTorch and run with a runtime that was never built next to it.

This PR reads the version in both readers, right after the root table is obtained, and refuses anything above the highest version the runtime supports. Program::load returns InvalidProgram, FlatTensorDataMap::load returns InvalidExternalData, which is what the checks around each of them return. Both name the two numbers:

Program schema version 1 is newer than the highest this runtime supports (0).
Export the model with an older ExecuTorch, or update the runtime.

The comparison is "less than or equal", not "equal", because the project promises that an older file keeps working on a newer runtime, and the schemas only ever grow by appending optional fields. Only a file from the future is refused.

Each new constant sits next to the class it guards, and points in a comment at the writer constants for the same number: EXECUTORCH_SCHEMA_VERSION for the program, kSchemaVersion and _FLAT_TENSOR_VERSION for the data file. A bump of one without the other is then easy to spot. Nothing writes a version other than zero today, so no file in the wild changes behavior. This is the reader side that a future version bump needs in order to mean anything.

One limit worth stating plainly: a runtime that is already deployed has no check at all, so this protects runtimes built from this change onward. That is inherent to adding a reader.

Test plan

Four new tests, two per format. Each pair builds a minimal file in memory through the real FlatBuffer builder, one stamped at the supported version and one above it. The first is expected to load and the second to be refused. The positive test is the control: without it, a rejection could just as well come from a malformed fixture as from the version. The program test asks for the cheapest verification level, which shows the check does not depend on full verification being enabled. The data test follows the byte layout that save_ptd() writes, with no segments.

Built and ran both suites on Linux x86_64, before and after the change:

cmake -S . -B cmake-out -DEXECUTORCH_BUILD_TESTS=ON \
-DEXECUTORCH_ENABLE_PROGRAM_VERIFICATION=ON \
-DEXECUTORCH_BUILD_EXTENSION_DATA_LOADER=ON \
-DEXECUTORCH_BUILD_EXTENSION_FLAT_TENSOR=ON
cmake --build cmake-out -j
./cmake-out/runtime/executor/test/program_test
./cmake-out/extension/flat_tensor/test/extension_flat_tensor_test

The existing tests pass unchanged in both runs, plus the four new ones. As a control, deleting either check while keeping the tests makes that format's negative test fail, with the newer file loading and reporting no error, so the check is what catches it.

Binary size, measured on the two object files. program.cpp.o grows by 24 bytes with logging disabled and 239 bytes with logging enabled. flat_tensor_data_map.cpp.o grows by 32 bytes with logging disabled and 243 bytes with logging enabled.

…read
Every exported PTE file carries a schema version. The exporter stamps it from
EXECUTORCH_SCHEMA_VERSION, and nothing in the runtime ever read it back. The only gate
on a file was the four byte identifier ET12, which says "this is an ExecuTorch program"
and says nothing about which shape of program it is. A file written by an exporter newer
than the runtime was therefore accepted, then misread field by field, and failed later
at load or during execution with an error that points somewhere else.
This matters more now that the two halves can come from different builds. The wheel
ships prebuilt runtime libraries, so a user can export with one installation of
ExecuTorch and run with a runtime that was never built next to it.
Read the version in Program::load, right after the root table is obtained, and refuse
anything above Program::kMaxSupportedSchemaVersion with InvalidProgram and a message
that names both numbers. The comparison is "less than or equal", not "equal", because
the project promises that an older file keeps working on a newer runtime, and the schema
only ever grows by appending optional fields. Only a file from the future is refused.
The new constant sits next to the class it guards, and the two constants point at each
other in comments so that a bump of one without the other is easy to spot. Nothing
writes a version other than zero today, so no file in the wild changes behavior. This is
the reader side that a future version bump needs in order to mean anything.
Two new tests build a minimal program through the real FlatBuffer builder, one stamped
at the supported version and one above it, and check that the first loads and the second
returns InvalidProgram at the cheapest verification level. Ran the program test suite on
Linux x86_64 before and after the change: the same tests pass in both, plus the two new
ones. Deleting the check and keeping the tests makes the negative test fail, with the
newer file loading successfully, so the check is what catches it. Measured on the object
file, the cost is 24 bytes with logging disabled and 239 bytes with logging enabled.
The companion PTD file has the same unread version field, and this change does not touch
it.
CopilotAI lite review requested due to automatic review settings August 24, 2026 23:14
@pytorch-bot

pytorch-botBot commented Aug 24, 2026

Copy link
Copy Markdown

🔗 Helpful Links

🧪 See artifacts and rendered test results at hud.pytorch.org/pr/pytorch/executorch/22114

Note: Links to docs will display an error until the docs builds have been completed.

❌ 1 New Failure, 2 Unclassified Failures

As of commit 8f613a8 with merge base 1afd07f (image):

NEW FAILURE - The following job has failed:

UNCLASSIFIED FAILURES - DrCI could not classify the following jobs because the workflow did not run on the merge base. The failures may be pre-existing on trunk or introduced by this PR:

This comment was automatically generated by Dr. CI and updates every 15 minutes.

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@meta-clameta-claBot added the CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. label Aug 24, 2026
@shoumikhinshoumikhin added the release notes: runtime Changes related to the core runtime which loads the program methods, initializes delegates, and runs label Aug 24, 2026
CopilotAI review requested due to automatic review settings August 24, 2026 23:19

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

…read
The data file has the same hole the program file had. Both writers, the C++ one in
serialize.h and the Python one in serialize.py, stamp a schema version into every PTD
file, and nothing in the runtime ever read it back. The four byte identifier FT01 was the
only gate, and it says nothing about which shape of data follows.
Read the version in FlatTensorDataMap::load, right after the root table is obtained, and
refuse anything above FlatTensorDataMap::kMaxSupportedSchemaVersion with
InvalidExternalData, which is what the checks around it return. The rule is the same as
for the program file: "less than or equal", so an older data file keeps loading.
Two new tests build a minimal PTD file in memory, one stamped at the supported version
and one above it. The positive one is the control. Without it, a rejection could just as
well come from a malformed fixture as from the version. Ran the data map suite on Linux
x86_64: the six existing tests pass unchanged and both new ones pass. Deleting the check
makes the negative test load the newer file with no error at all, so the check is what
catches it. Measured on the object file, the cost is 32 bytes with logging disabled and
243 bytes with logging enabled.
CopilotAI review requested due to automatic review settings August 24, 2026 23:30

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@shoumikhinshoumikhin changed the title Refuse a PTE file whose schema version is newer than the runtime can readRefuse a PTE or PTD file whose schema version is newer than the runtime can readAug 24, 2026
The comment next to each version check said the four byte identifier "only
says that this is a program" / "FlatTensor data". That undersells it. The
identifier is itself the coarse compatibility gate: ET12 and FT01 are bumped
only on a backward-incompatible schema change, so the identifier selects a
schema family and check_header already rejects a file from a different family
as IncompatibleVersion. The new version scalar is the finer gate within one
family. Reword both comments to say that, so a future reader does not conclude
the identifier carries no version meaning. Comment-only, no behavior change.
CopilotAI review requested due to automatic review settings August 25, 2026 00:29

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@GasoonjiaGasoonjia left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for doing the BC/FC check! I think there're two questions im not sure clear:

  1. When should we update the ExecuTorch version? What's the rule behind it?
  2. Is there any ci to mandatory make the version bump happen?

@Gasoonjia

Copy link
Copy Markdown
Contributor

@claude review the PR

@claude

claudeBot commented Aug 25, 2026

Copy link
Copy Markdown

Claude finished @Gasoonjia's task in 2m 7s —— View job


PR Review: Refuse a PTE/PTD file whose schema version is newer than the runtime can read

I read the full diff plus the surrounding load paths in program.cpp and flat_tensor_data_map.cpp. This is a clean, well-scoped change and the reasoning in the description is sound. Below is my feedback, followed by answers to @Gasoonjia's two questions.

What's good

  • Both checks sit in the right place — immediately after the root table pointer is obtained (program.cpp:234, flat_tensor_data_map.cpp:301) and return the same error the surrounding code already uses (InvalidProgram / InvalidExternalData).
  • <= rather than == is the correct choice given the append-only-optional-fields FC promise, and the error messages name both numbers and tell the user what to do.
  • Because version() defaults to 0 when the field is absent, files written before the field existed still load. And since nothing stamps a non-zero version today, this is inert in the wild — low risk.
  • The positive/negative test pairing is the right shape; the program negative test using Verification::Minimal nicely proves the gate doesn't depend on full verification.

The real gap — nothing enforces the constants stay in sync

This is the same concern behind @Gasoonjia's questions. The correctness of the gate depends on four hand-maintained constants agreeing:

ConstantFile
EXECUTORCH_SCHEMA_VERSIONexir/version.py
Program::kMaxSupportedSchemaVersionruntime/executor/program.h:85
_FLAT_TENSOR_VERSIONextension/flat_tensor/serialize/serialize.py:48
kSchemaVersionextension/flat_tensor/serialize/serialize.h
FlatTensorDataMap::kMaxSupportedSchemaVersionextension/flat_tensor/flat_tensor_data_map.h

Right now the only thing tying them together is the cross-reference comments. If someone bumps the writer (exir/version.py) and forgets the reader (program.h), the failure mode is exactly the silent misread this PR is trying to prevent — the reader would reject files it can and should read (an FC regression), and the comments alone won't catch it in review.

Consider adding a tiny guard so a bump can't drift:

  • A C++ static_assert(Program::kMaxSupportedSchemaVersion == kSchemaVersion ...) where the headers are both visible, and/or
  • A Python unit test that reads both .h files (or a generated constant) and asserts equality with EXECUTORCH_SCHEMA_VERSION / _FLAT_TENSOR_VERSION.

That would answer question 2 directly and make the comments enforceable rather than advisory.

Minor

  • Reading version() before full verification. In Verification::Minimal mode only root_offset is bounds-checked (program.cpp:216); flatbuffer_program->version() then dereferences the vtable of an otherwise-unverified buffer. This is consistent with the existing code (named_data(), constant_segment() are read the same way right after), so it's not a regression — just noting the version read inherits the same trust assumptions as everything below it. No change needed.
  • Comment claim about the file identifier. The comments state the 4-byte identifier "changes only on a backward-incompatible schema change." That's the intended contract, but I don't see anything in the schema or tooling that enforces the identifier bumps on a breaking change — it's a convention. Fine to keep, just be aware it's an assertion of intent, not a guarantee the code makes.
  • reinterpret_cast<const uint32_t*>(flatbuffer) in the flat_tensor test assumes little-endian + aligned start. Acceptable for a test fixture, and it mirrors save_ptd(), but it's the fragile part of the suite if the header layout ever changes.

Answers to @Gasoonjia

  1. When should the version bump? The schemas are append-only: adding an optional field is backward- and forward-compatible and does not require a bump (that's the whole point of the <= check). A bump is only meaningful when a change makes a new file unreadable-as-old by an old runtime — i.e. a semantic change to an existing field, or a new field the runtime must understand to execute correctly. Purely additive optional fields shouldn't bump; changes that old runtimes would silently misinterpret should. It would be worth stating this rule explicitly in a comment next to EXECUTORCH_SCHEMA_VERSION, since this PR is what gives the number teeth.
  2. Is there CI to mandate the bump? Not that I can find, and this PR doesn't add one. There's no check that a schema (.fbs) change forces a version bump, nor that the writer/reader constants match. The sync-assert / test suggested above covers the second half (constants agreeing); mandating a bump on .fbs changes is harder and probably out of scope here, but worth a follow-up issue.

Overall: correct and low-risk as written. The one thing I'd push for before merge is a mechanical guard that the writer and reader constants can't diverge — otherwise the whole mechanism is one forgotten edit away from silently regressing FC.
• branch shoumikhin/program-version-gate

CopilotAI review requested due to automatic review settings August 25, 2026 01:16

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

The version gate this PR adds is only correct while five hand-maintained
constants agree: the PTE writer (EXECUTORCH_SCHEMA_VERSION) and its runtime
reader (Program::kMaxSupportedSchemaVersion), and the two PTD writers
(_FLAT_TENSOR_VERSION, kSchemaVersion) and their reader
(FlatTensorDataMap::kMaxSupportedSchemaVersion). Until now only cross-reference
comments tied them together, so bumping a writer without its reader would make a
runtime refuse files it should read, and bumping one PTD writer without the
other would let a file be stamped below its real layout and misread.
Extend schema/test/test_schema.py, which already enforces cross-file schema
sync and runs in OSS CI with no build wiring, with a check that parses the five
literals as text and asserts the compatibility relationship: each writer must be
<= its reader ceiling (a reader may support a version before any writer emits
it, but never the reverse), and the two PTD writers must be exactly equal
because they stamp the same field of the same file. The parse keys on file path,
not symbol, since the two reader ceilings share a name, and fails closed if a
constant can no longer be found so a reformat can't silently disable the guard.
Also state the bump rule next to EXECUTORCH_SCHEMA_VERSION and soften the
file-identifier comments: the identifier is bumped by convention on a breaking
change, not by anything the code enforces.
CopilotAI review requested due to automatic review settings August 25, 2026 01:22

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@shoumikhin

Copy link
Copy Markdown
ContributorAuthor

Thanks @claude — good catch on the drift risk. I added the guard, and it's the right call to do it in this PR since this is what turns the constants into a real gate.

What I added: a check in schema/test/test_schema.py (the file that already enforces cross-file schema sync and runs in OSS CI, so no new build wiring). It reads all five version constants as text and asserts:

  • PTE: EXECUTORCH_SCHEMA_VERSIONProgram::kMaxSupportedSchemaVersion
  • PTD: _FLAT_TENSOR_VERSION == kSchemaVersionFlatTensorDataMap::kMaxSupportedSchemaVersion

The relationship is writer-≤-reader, not strict equality: a runtime is allowed to support a version before any writer emits it (forward-compat), but a writer must never stamp a version its reader would refuse. The two PTD writers are compared with == because they stamp the same field of the same file. The parse keys on the file path (the two reader ceilings share the name kMaxSupportedSchemaVersion) and fails if a constant can't be found, so a rename/reformat can't silently disable the check.

I verified it locally: passes today, fails when I bump one writer without its reader, fails when the two PTD writers disagree, and still passes when only a reader is bumped ahead (the legitimate case).

On the minors:

  • Softened both file-identifier comments to say the tag is bumped by convention on a breaking change — you're right it's not code-enforced.
  • Added the bump rule you described next to EXECUTORCH_SCHEMA_VERSION.
  • Left the version()-before-verification read and the test's reinterpret_cast as-is, since you flagged both as no-change-needed (the version read inherits the same trust as the reads around it; the cast mirrors save_ptd()).

On mandating a bump when a .fbs changes: still out of scope here (it needs schema-shape diffing), but the constant-sync half is now covered. Happy to file a follow-up for the .fbs-change mandate and for the Python PTD reader still using != (separate small PR).

@shoumikhin

Copy link
Copy Markdown
ContributorAuthor

@claude review the PR

@claude

claudeBot commented Aug 25, 2026

Copy link
Copy Markdown

Claude finished @shoumikhin's task in 1m 32s —— View job


Re: the drift guard (8f613a8)

I read the new test_schema.py::test_schema_version_constants_in_sync and traced all five regexes against the actual declarations. This resolves the concern from my earlier review — the guard is correct and lands in the right place.

Verified the five patterns match today's source:

ConstantDeclarationRegex
EXECUTORCH_SCHEMA_VERSION= 0 (exir/version.py:14)
Program::kMaxSupportedSchemaVersion= 0 (program.h:85)
_FLAT_TENSOR_VERSION: int = 0 (serialize.py:49)
kSchemaVersion= 0 (serialize.h:26)
FlatTensorDataMap::kMaxSupportedSchemaVersion= 0 (flat_tensor_data_map.h:47)

The design decisions are right:

  • Keying on file path, not symbol name is the correct call — the two reader ceilings both spell kMaxSupportedSchemaVersion, so a symbol-based parse would be ambiguous. Path-keyed re.search on each file avoids that, and since kMaxSupportedSchemaVersion doesn't contain the literal substring kSchemaVersion, the PTD-writer regex in serialize.h can't accidentally match a ceiling either.
  • Writer <= reader, PTD writers == matches the compatibility policy exactly: a runtime may support a version before any writer emits it (FC), but a writer must never stamp a version its reader refuses; and the two PTD writers stamp the same field, so they must be equal. The asymmetry is intentional and correctly encoded.
  • Fail-closed on a missing constant (assertIsNotNone with a message that tells the next editor to fix the test) is what makes a rename/reformat surface loudly instead of silently disabling the guard — this was the exact failure mode I was worried about.
  • Putting it in test_schema.py (already in OSS CI, no new build wiring) is the pragmatic choice over a static_assert, which couldn't reach the Python writers anyway.

On the minors — all handled as expected: the file-identifier comments now say "by convention," the bump rule is stated next to EXECUTORCH_SCHEMA_VERSION, and leaving the version()-before-verification read and the test reinterpret_cast as-is is fine.

One small note (non-blocking): the guard proves the five constants agree with each other, but the writer/reader ceiling and the actual serialized field are still tied only by the runtime code that reads version(). That's fine — that path is what your four new C++ tests exercise, so the two halves (constants-agree here, field-is-gated there) are each covered.

On follow-ups: filing separate issues for (a) mandating a version bump on .fbs shape changes and (b) the Python PTD reader still using != instead of <= sounds right — both are genuinely out of scope for this PR, and the constant-sync half was the load-bearing gap.

I couldn't run the suite here (sandbox blocked python), but the parse is simple text matching and I verified each pattern against its literal by hand; your local run (passes today, fails on each induced drift) covers the dynamic behavior.

LGTM.
• branch shoumikhin/program-version-gate

@claude

claudeBot commented Aug 25, 2026

Copy link
Copy Markdown

Claude finished @shoumikhin's task in 2m 4s —— View job


Re-review: Refuse a PTE/PTD file whose schema version is newer than the runtime can read

I re-read the full diff against origin/main, focused on the commit added since my last pass (the constant-sync guard) plus a fresh look at the readers and tests. My earlier concern — that the whole mechanism was one forgotten edit away from silently regressing FC — is now addressed. Summary: correct, low-risk, and I'd merge it.

The drift guard resolves the main gap

schema/test/test_schema.py::test_schema_version_constants_in_sync is the right fix in the right place — it lives in the file that already enforces cross-file schema sync and runs in OSS CI, so no new build wiring. Reviewing the logic:

  • Text-parse over import is the correct call here (importing the writers drags in torch; the C++ constants have no Python binding), and keying the parse on file path rather than symbol name correctly disambiguates the two reader ceilings that share kMaxSupportedSchemaVersion (program.h:80, flat_tensor_data_map.h:46).
  • The assertIsNotNone on every match means a rename or reformat fails the test loudly instead of silently disabling the check — exactly the failure mode you'd want to avoid in a guard like this.
  • The relations model the compatibility policy correctly: writer--reader for each family (a runtime may support a version before any writer emits it), and == between the two PTD writers (they stamp the same field of the same file). PTE and PTD kept independent.

Readers — verified placement

  • Program::load (program.cpp:226) — the check sits right after the root-table pointer and before named_data/constant_segment reads, returning InvalidProgram. Correct.
  • FlatTensorDataMap (flat_tensor_data_map.cpp:301) — same shape, returns InvalidExternalData. Correct.
  • <= (not ==), version defaulting to 0 when absent, and both numbers named in the message with an actionable fix — all as described.

Tests

  • The positive/negative pairing per format is the right shape, and the program negative test using Verification::Minimal proves the gate doesn't depend on full verification. The negative fixtures are otherwise-valid files differing only in the version field, so the rejection is attributable to the gate and nothing else.
  • The flat_tensor fixture's reinterpret_cast<const uint32_t*>(flatbuffer) assumes little-endian + aligned start — fine for a test fixture and it mirrors save_ptd(), as previously noted. No change needed.

Minors addressed

  • Both file-identifier comments now say the tag is bumped by convention — matches reality (the identifier isn't code-enforced to bump on a break).
  • The bump rule is now documented next to EXECUTORCH_SCHEMA_VERSION (exir/version.py), which is what gives the number teeth.

One optional follow-up (non-blocking)

As you noted, the guard covers the constants agreeing half. It does not mandate a version bump when a .fbs actually changes shape — that needs schema-shape diffing and is reasonably out of scope. Worth a follow-up issue, along with the Python PTD reader still comparing with != (serialize.py) rather than the <= semantics this PR establishes on the C++ side, so a future non-zero PTD version wouldn't regress the Python path.

Nothing here blocks merge. Nice work turning the cross-reference comments into an enforced check within this same PR.
• branch shoumikhin/program-version-gate

<cstring> and <vector> already arrive through buffer_data_loader.h and the
generated flatbuffers header, so drop them. Replace the per-symbol using
declarations with namespace directives, matching the convention used elsewhere.
CopilotAI review requested due to automatic review settings August 25, 2026 02:07

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@shoumikhin
shoumikhin merged commit d181029 into mainAug 25, 2026
212 of 213 checks passed
@shoumikhin
shoumikhin deleted the shoumikhin/program-version-gate branch August 25, 2026 02:44
shoumikhin added a commit that referenced this pull request Aug 25, 2026
…22117)
### Summary
The Python `FlatTensorSerializer.deserialize` refused any PTD file whose
`version` did not **exactly** equal `_FLAT_TENSOR_VERSION`:
```python
if flat_tensor.version != _FLAT_TENSOR_VERSION:
raise NotImplementedError(...)
```
That rejects an **older** file too, which contradicts:
- the append-only schema policy in `schema/README.md` (older files stay
loadable), and
- the C++ runtime readers, which accept anything `<=` their supported
version and only refuse a file **newer** than they understand
(`Program::load`, `FlatTensorDataMap::load`).
This aligns the Python PTD reader with that policy: compare with `>`
instead of `!=`, so an older or equal file loads and only a newer one is
refused. The error message now says the file is newer than this reader
supports.
### Context
This was called out as a follow-up during review of #22114 (which adds
the `<=` gate on the C++ side). It keeps the Python export/tooling path
consistent so a future non-zero PTD version won't regress it.
### Test plan
Two tests in `extension/flat_tensor/test/test_serialize.py`:
- `test_deserialize_refuses_newer_version` — a bumped-version file is
refused.
- `test_deserialize_accepts_older_version` — a file older than the
reader still loads (the case the old `!=` wrongly rejected).
Both are behavior-only; no schema or format change. Version constants
are unchanged (still `0`), so this is inert for existing files and only
changes behavior once a non-zero version is ever stamped.
shoumikhin added a commit that referenced this pull request Aug 25, 2026
### Summary
`FlatTensorDataMap::load()` reads the flatbuffer root table without
first bounds-checking the root offset:
```cpp
const flat_tensor_flatbuffer::FlatTensor* flat_tensor =
flat_tensor_flatbuffer::GetFlatTensor(flat_tensor_data->data());
// then immediately:
flat_tensor->named_data(); // walks the root vtable
flat_tensor->segments();
```
`GetFlatTensor()` interprets the `uoffset_t` at the start of the buffer
as the root-table offset and returns `buf + offset`. The first field
access then walks that table's vtable. Nothing on this path runs full
flatbuffer verification, so a corrupt or truncated PTD file with a bad
root offset makes these accessors dereference memory **outside the
buffer** (an OOB read, flagged by ASan).
The program loader already guards against exactly this in
`runtime/executor/program.cpp`. This change mirrors that guard for PTD
files.
### What changed
After the existing identifier and alignment checks, before
`GetFlatTensor()`:
- Confirm the buffer is at least a flatbuffer header long (`uoffset_t` +
file identifier).
- Confirm the root offset is `>= kMinBufferSize` and leaves room for a
vtable `soffset_t` within the buffer.
- Return `InvalidExternalData` otherwise (the same error the surrounding
checks use).
FlatTensor is **not** size-prefixed — both writers finish the buffer
plain (`builder.Finish` in C++, default `flatc` in Python) and the
reader uses `GetFlatTensor` (not the size-prefixed variant) — so the
root offset is at byte 0, exactly as in the program case.
### Scope
This is a pre-existing hardening, independent of the schema-version work
in #22114 (the version gate this protects is additive; the OOB path
exists today via `named_data()`/`segments()`).
### Test plan
New `FlatTensorDataMapTest.RejectsOutOfBoundsRootOffset`: copies a valid
PTD into a max-aligned buffer, overwrites only the root offset with a
value past the end (leaving identifier + alignment intact), and asserts
`load()` returns `InvalidExternalData`. Without the check, that same
input is an out-of-bounds vtable read under ASan.
> Note: I was unable to build/run the C++ suite in my local environment,
so I'm relying on this PR's CI (which builds the runtime and runs
`extension/flat_tensor` tests, including under ASan) as the
authoritative check. The change mirrors an existing, tested guard in
`program.cpp`.
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA SignedThis label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed.release notes: runtimeChanges related to the core runtime which loads the program methods, initializes delegates, and runs

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants

@shoumikhin@Gasoonjia@huydhn
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Refuse a PTE or PTD file whose schema version is newer than the runtime can read - #22114

Merged
shoumikhin merged 8 commits into
mainfrom
shoumikhin/program-version-gate
Aug 25, 2026
Merged

Refuse a PTE or PTD file whose schema version is newer than the runtime can read#22114
shoumikhin merged 8 commits into
mainfrom
shoumikhin/program-version-gate

Conversation

@shoumikhin

@shoumikhinshoumikhin commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

Summary

Every exported PTE file and every PTD data file carries a schema version. The exporters stamp it, and nothing in the runtime ever read it back. The only gate on a file was its four byte identifier, ET12 for a program and FT01 for data. That says "this is an ExecuTorch file" and says nothing about which shape of file it is. A file written by an exporter newer than the runtime was therefore accepted, then misread field by field, and failed later at load or during execution with an error that points somewhere else.

This matters more now that the two halves can come from different builds. The wheel ships prebuilt runtime libraries, so a user can export with one installation of ExecuTorch and run with a runtime that was never built next to it.

This PR reads the version in both readers, right after the root table is obtained, and refuses anything above the highest version the runtime supports. Program::load returns InvalidProgram, FlatTensorDataMap::load returns InvalidExternalData, which is what the checks around each of them return. Both name the two numbers:

Program schema version 1 is newer than the highest this runtime supports (0).
Export the model with an older ExecuTorch, or update the runtime.

The comparison is "less than or equal", not "equal", because the project promises that an older file keeps working on a newer runtime, and the schemas only ever grow by appending optional fields. Only a file from the future is refused.

Each new constant sits next to the class it guards, and points in a comment at the writer constants for the same number: EXECUTORCH_SCHEMA_VERSION for the program, kSchemaVersion and _FLAT_TENSOR_VERSION for the data file. A bump of one without the other is then easy to spot. Nothing writes a version other than zero today, so no file in the wild changes behavior. This is the reader side that a future version bump needs in order to mean anything.

One limit worth stating plainly: a runtime that is already deployed has no check at all, so this protects runtimes built from this change onward. That is inherent to adding a reader.

Test plan

Four new tests, two per format. Each pair builds a minimal file in memory through the real FlatBuffer builder, one stamped at the supported version and one above it. The first is expected to load and the second to be refused. The positive test is the control: without it, a rejection could just as well come from a malformed fixture as from the version. The program test asks for the cheapest verification level, which shows the check does not depend on full verification being enabled. The data test follows the byte layout that save_ptd() writes, with no segments.

Built and ran both suites on Linux x86_64, before and after the change:

cmake -S . -B cmake-out -DEXECUTORCH_BUILD_TESTS=ON \
-DEXECUTORCH_ENABLE_PROGRAM_VERIFICATION=ON \
-DEXECUTORCH_BUILD_EXTENSION_DATA_LOADER=ON \
-DEXECUTORCH_BUILD_EXTENSION_FLAT_TENSOR=ON
cmake --build cmake-out -j
./cmake-out/runtime/executor/test/program_test
./cmake-out/extension/flat_tensor/test/extension_flat_tensor_test

The existing tests pass unchanged in both runs, plus the four new ones. As a control, deleting either check while keeping the tests makes that format's negative test fail, with the newer file loading and reporting no error, so the check is what catches it.

Binary size, measured on the two object files. program.cpp.o grows by 24 bytes with logging disabled and 239 bytes with logging enabled. flat_tensor_data_map.cpp.o grows by 32 bytes with logging disabled and 243 bytes with logging enabled.

…read
Every exported PTE file carries a schema version. The exporter stamps it from
EXECUTORCH_SCHEMA_VERSION, and nothing in the runtime ever read it back. The only gate
on a file was the four byte identifier ET12, which says "this is an ExecuTorch program"
and says nothing about which shape of program it is. A file written by an exporter newer
than the runtime was therefore accepted, then misread field by field, and failed later
at load or during execution with an error that points somewhere else.
This matters more now that the two halves can come from different builds. The wheel
ships prebuilt runtime libraries, so a user can export with one installation of
ExecuTorch and run with a runtime that was never built next to it.
Read the version in Program::load, right after the root table is obtained, and refuse
anything above Program::kMaxSupportedSchemaVersion with InvalidProgram and a message
that names both numbers. The comparison is "less than or equal", not "equal", because
the project promises that an older file keeps working on a newer runtime, and the schema
only ever grows by appending optional fields. Only a file from the future is refused.
The new constant sits next to the class it guards, and the two constants point at each
other in comments so that a bump of one without the other is easy to spot. Nothing
writes a version other than zero today, so no file in the wild changes behavior. This is
the reader side that a future version bump needs in order to mean anything.
Two new tests build a minimal program through the real FlatBuffer builder, one stamped
at the supported version and one above it, and check that the first loads and the second
returns InvalidProgram at the cheapest verification level. Ran the program test suite on
Linux x86_64 before and after the change: the same tests pass in both, plus the two new
ones. Deleting the check and keeping the tests makes the negative test fail, with the
newer file loading successfully, so the check is what catches it. Measured on the object
file, the cost is 24 bytes with logging disabled and 239 bytes with logging enabled.
The companion PTD file has the same unread version field, and this change does not touch
it.
CopilotAI lite review requested due to automatic review settings August 24, 2026 23:14
@pytorch-bot

pytorch-botBot commented Aug 24, 2026

Copy link
Copy Markdown

🔗 Helpful Links

🧪 See artifacts and rendered test results at hud.pytorch.org/pr/pytorch/executorch/22114

Note: Links to docs will display an error until the docs builds have been completed.

❌ 1 New Failure, 2 Unclassified Failures

As of commit 8f613a8 with merge base 1afd07f (image):

NEW FAILURE - The following job has failed:

UNCLASSIFIED FAILURES - DrCI could not classify the following jobs because the workflow did not run on the merge base. The failures may be pre-existing on trunk or introduced by this PR:

This comment was automatically generated by Dr. CI and updates every 15 minutes.

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@meta-clameta-claBot added the CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. label Aug 24, 2026
@shoumikhinshoumikhin added the release notes: runtime Changes related to the core runtime which loads the program methods, initializes delegates, and runs label Aug 24, 2026
CopilotAI review requested due to automatic review settings August 24, 2026 23:19

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

…read
The data file has the same hole the program file had. Both writers, the C++ one in
serialize.h and the Python one in serialize.py, stamp a schema version into every PTD
file, and nothing in the runtime ever read it back. The four byte identifier FT01 was the
only gate, and it says nothing about which shape of data follows.
Read the version in FlatTensorDataMap::load, right after the root table is obtained, and
refuse anything above FlatTensorDataMap::kMaxSupportedSchemaVersion with
InvalidExternalData, which is what the checks around it return. The rule is the same as
for the program file: "less than or equal", so an older data file keeps loading.
Two new tests build a minimal PTD file in memory, one stamped at the supported version
and one above it. The positive one is the control. Without it, a rejection could just as
well come from a malformed fixture as from the version. Ran the data map suite on Linux
x86_64: the six existing tests pass unchanged and both new ones pass. Deleting the check
makes the negative test load the newer file with no error at all, so the check is what
catches it. Measured on the object file, the cost is 32 bytes with logging disabled and
243 bytes with logging enabled.
CopilotAI review requested due to automatic review settings August 24, 2026 23:30

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@shoumikhinshoumikhin changed the title Refuse a PTE file whose schema version is newer than the runtime can readRefuse a PTE or PTD file whose schema version is newer than the runtime can readAug 24, 2026
The comment next to each version check said the four byte identifier "only
says that this is a program" / "FlatTensor data". That undersells it. The
identifier is itself the coarse compatibility gate: ET12 and FT01 are bumped
only on a backward-incompatible schema change, so the identifier selects a
schema family and check_header already rejects a file from a different family
as IncompatibleVersion. The new version scalar is the finer gate within one
family. Reword both comments to say that, so a future reader does not conclude
the identifier carries no version meaning. Comment-only, no behavior change.
CopilotAI review requested due to automatic review settings August 25, 2026 00:29

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@GasoonjiaGasoonjia left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for doing the BC/FC check! I think there're two questions im not sure clear:

  1. When should we update the ExecuTorch version? What's the rule behind it?
  2. Is there any ci to mandatory make the version bump happen?

@Gasoonjia

Copy link
Copy Markdown
Contributor

@claude review the PR

@claude

claudeBot commented Aug 25, 2026

Copy link
Copy Markdown

Claude finished @Gasoonjia's task in 2m 7s —— View job


PR Review: Refuse a PTE/PTD file whose schema version is newer than the runtime can read

I read the full diff plus the surrounding load paths in program.cpp and flat_tensor_data_map.cpp. This is a clean, well-scoped change and the reasoning in the description is sound. Below is my feedback, followed by answers to @Gasoonjia's two questions.

What's good

  • Both checks sit in the right place — immediately after the root table pointer is obtained (program.cpp:234, flat_tensor_data_map.cpp:301) and return the same error the surrounding code already uses (InvalidProgram / InvalidExternalData).
  • <= rather than == is the correct choice given the append-only-optional-fields FC promise, and the error messages name both numbers and tell the user what to do.
  • Because version() defaults to 0 when the field is absent, files written before the field existed still load. And since nothing stamps a non-zero version today, this is inert in the wild — low risk.
  • The positive/negative test pairing is the right shape; the program negative test using Verification::Minimal nicely proves the gate doesn't depend on full verification.

The real gap — nothing enforces the constants stay in sync

This is the same concern behind @Gasoonjia's questions. The correctness of the gate depends on four hand-maintained constants agreeing:

ConstantFile
EXECUTORCH_SCHEMA_VERSIONexir/version.py
Program::kMaxSupportedSchemaVersionruntime/executor/program.h:85
_FLAT_TENSOR_VERSIONextension/flat_tensor/serialize/serialize.py:48
kSchemaVersionextension/flat_tensor/serialize/serialize.h
FlatTensorDataMap::kMaxSupportedSchemaVersionextension/flat_tensor/flat_tensor_data_map.h

Right now the only thing tying them together is the cross-reference comments. If someone bumps the writer (exir/version.py) and forgets the reader (program.h), the failure mode is exactly the silent misread this PR is trying to prevent — the reader would reject files it can and should read (an FC regression), and the comments alone won't catch it in review.

Consider adding a tiny guard so a bump can't drift:

  • A C++ static_assert(Program::kMaxSupportedSchemaVersion == kSchemaVersion ...) where the headers are both visible, and/or
  • A Python unit test that reads both .h files (or a generated constant) and asserts equality with EXECUTORCH_SCHEMA_VERSION / _FLAT_TENSOR_VERSION.

That would answer question 2 directly and make the comments enforceable rather than advisory.

Minor

  • Reading version() before full verification. In Verification::Minimal mode only root_offset is bounds-checked (program.cpp:216); flatbuffer_program->version() then dereferences the vtable of an otherwise-unverified buffer. This is consistent with the existing code (named_data(), constant_segment() are read the same way right after), so it's not a regression — just noting the version read inherits the same trust assumptions as everything below it. No change needed.
  • Comment claim about the file identifier. The comments state the 4-byte identifier "changes only on a backward-incompatible schema change." That's the intended contract, but I don't see anything in the schema or tooling that enforces the identifier bumps on a breaking change — it's a convention. Fine to keep, just be aware it's an assertion of intent, not a guarantee the code makes.
  • reinterpret_cast<const uint32_t*>(flatbuffer) in the flat_tensor test assumes little-endian + aligned start. Acceptable for a test fixture, and it mirrors save_ptd(), but it's the fragile part of the suite if the header layout ever changes.

Answers to @Gasoonjia

  1. When should the version bump? The schemas are append-only: adding an optional field is backward- and forward-compatible and does not require a bump (that's the whole point of the <= check). A bump is only meaningful when a change makes a new file unreadable-as-old by an old runtime — i.e. a semantic change to an existing field, or a new field the runtime must understand to execute correctly. Purely additive optional fields shouldn't bump; changes that old runtimes would silently misinterpret should. It would be worth stating this rule explicitly in a comment next to EXECUTORCH_SCHEMA_VERSION, since this PR is what gives the number teeth.
  2. Is there CI to mandate the bump? Not that I can find, and this PR doesn't add one. There's no check that a schema (.fbs) change forces a version bump, nor that the writer/reader constants match. The sync-assert / test suggested above covers the second half (constants agreeing); mandating a bump on .fbs changes is harder and probably out of scope here, but worth a follow-up issue.

Overall: correct and low-risk as written. The one thing I'd push for before merge is a mechanical guard that the writer and reader constants can't diverge — otherwise the whole mechanism is one forgotten edit away from silently regressing FC.
• branch shoumikhin/program-version-gate

CopilotAI review requested due to automatic review settings August 25, 2026 01:16

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

The version gate this PR adds is only correct while five hand-maintained
constants agree: the PTE writer (EXECUTORCH_SCHEMA_VERSION) and its runtime
reader (Program::kMaxSupportedSchemaVersion), and the two PTD writers
(_FLAT_TENSOR_VERSION, kSchemaVersion) and their reader
(FlatTensorDataMap::kMaxSupportedSchemaVersion). Until now only cross-reference
comments tied them together, so bumping a writer without its reader would make a
runtime refuse files it should read, and bumping one PTD writer without the
other would let a file be stamped below its real layout and misread.
Extend schema/test/test_schema.py, which already enforces cross-file schema
sync and runs in OSS CI with no build wiring, with a check that parses the five
literals as text and asserts the compatibility relationship: each writer must be
<= its reader ceiling (a reader may support a version before any writer emits
it, but never the reverse), and the two PTD writers must be exactly equal
because they stamp the same field of the same file. The parse keys on file path,
not symbol, since the two reader ceilings share a name, and fails closed if a
constant can no longer be found so a reformat can't silently disable the guard.
Also state the bump rule next to EXECUTORCH_SCHEMA_VERSION and soften the
file-identifier comments: the identifier is bumped by convention on a breaking
change, not by anything the code enforces.
CopilotAI review requested due to automatic review settings August 25, 2026 01:22

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@shoumikhin

Copy link
Copy Markdown
ContributorAuthor

Thanks @claude — good catch on the drift risk. I added the guard, and it's the right call to do it in this PR since this is what turns the constants into a real gate.

What I added: a check in schema/test/test_schema.py (the file that already enforces cross-file schema sync and runs in OSS CI, so no new build wiring). It reads all five version constants as text and asserts:

  • PTE: EXECUTORCH_SCHEMA_VERSIONProgram::kMaxSupportedSchemaVersion
  • PTD: _FLAT_TENSOR_VERSION == kSchemaVersionFlatTensorDataMap::kMaxSupportedSchemaVersion

The relationship is writer-≤-reader, not strict equality: a runtime is allowed to support a version before any writer emits it (forward-compat), but a writer must never stamp a version its reader would refuse. The two PTD writers are compared with == because they stamp the same field of the same file. The parse keys on the file path (the two reader ceilings share the name kMaxSupportedSchemaVersion) and fails if a constant can't be found, so a rename/reformat can't silently disable the check.

I verified it locally: passes today, fails when I bump one writer without its reader, fails when the two PTD writers disagree, and still passes when only a reader is bumped ahead (the legitimate case).

On the minors:

  • Softened both file-identifier comments to say the tag is bumped by convention on a breaking change — you're right it's not code-enforced.
  • Added the bump rule you described next to EXECUTORCH_SCHEMA_VERSION.
  • Left the version()-before-verification read and the test's reinterpret_cast as-is, since you flagged both as no-change-needed (the version read inherits the same trust as the reads around it; the cast mirrors save_ptd()).

On mandating a bump when a .fbs changes: still out of scope here (it needs schema-shape diffing), but the constant-sync half is now covered. Happy to file a follow-up for the .fbs-change mandate and for the Python PTD reader still using != (separate small PR).

@shoumikhin

Copy link
Copy Markdown
ContributorAuthor

@claude review the PR

@claude

claudeBot commented Aug 25, 2026

Copy link
Copy Markdown

Claude finished @shoumikhin's task in 1m 32s —— View job


Re: the drift guard (8f613a8)

I read the new test_schema.py::test_schema_version_constants_in_sync and traced all five regexes against the actual declarations. This resolves the concern from my earlier review — the guard is correct and lands in the right place.

Verified the five patterns match today's source:

ConstantDeclarationRegex
EXECUTORCH_SCHEMA_VERSION= 0 (exir/version.py:14)
Program::kMaxSupportedSchemaVersion= 0 (program.h:85)
_FLAT_TENSOR_VERSION: int = 0 (serialize.py:49)
kSchemaVersion= 0 (serialize.h:26)
FlatTensorDataMap::kMaxSupportedSchemaVersion= 0 (flat_tensor_data_map.h:47)

The design decisions are right:

  • Keying on file path, not symbol name is the correct call — the two reader ceilings both spell kMaxSupportedSchemaVersion, so a symbol-based parse would be ambiguous. Path-keyed re.search on each file avoids that, and since kMaxSupportedSchemaVersion doesn't contain the literal substring kSchemaVersion, the PTD-writer regex in serialize.h can't accidentally match a ceiling either.
  • Writer <= reader, PTD writers == matches the compatibility policy exactly: a runtime may support a version before any writer emits it (FC), but a writer must never stamp a version its reader refuses; and the two PTD writers stamp the same field, so they must be equal. The asymmetry is intentional and correctly encoded.
  • Fail-closed on a missing constant (assertIsNotNone with a message that tells the next editor to fix the test) is what makes a rename/reformat surface loudly instead of silently disabling the guard — this was the exact failure mode I was worried about.
  • Putting it in test_schema.py (already in OSS CI, no new build wiring) is the pragmatic choice over a static_assert, which couldn't reach the Python writers anyway.

On the minors — all handled as expected: the file-identifier comments now say "by convention," the bump rule is stated next to EXECUTORCH_SCHEMA_VERSION, and leaving the version()-before-verification read and the test reinterpret_cast as-is is fine.

One small note (non-blocking): the guard proves the five constants agree with each other, but the writer/reader ceiling and the actual serialized field are still tied only by the runtime code that reads version(). That's fine — that path is what your four new C++ tests exercise, so the two halves (constants-agree here, field-is-gated there) are each covered.

On follow-ups: filing separate issues for (a) mandating a version bump on .fbs shape changes and (b) the Python PTD reader still using != instead of <= sounds right — both are genuinely out of scope for this PR, and the constant-sync half was the load-bearing gap.

I couldn't run the suite here (sandbox blocked python), but the parse is simple text matching and I verified each pattern against its literal by hand; your local run (passes today, fails on each induced drift) covers the dynamic behavior.

LGTM.
• branch shoumikhin/program-version-gate

@claude

claudeBot commented Aug 25, 2026

Copy link
Copy Markdown

Claude finished @shoumikhin's task in 2m 4s —— View job


Re-review: Refuse a PTE/PTD file whose schema version is newer than the runtime can read

I re-read the full diff against origin/main, focused on the commit added since my last pass (the constant-sync guard) plus a fresh look at the readers and tests. My earlier concern — that the whole mechanism was one forgotten edit away from silently regressing FC — is now addressed. Summary: correct, low-risk, and I'd merge it.

The drift guard resolves the main gap

schema/test/test_schema.py::test_schema_version_constants_in_sync is the right fix in the right place — it lives in the file that already enforces cross-file schema sync and runs in OSS CI, so no new build wiring. Reviewing the logic:

  • Text-parse over import is the correct call here (importing the writers drags in torch; the C++ constants have no Python binding), and keying the parse on file path rather than symbol name correctly disambiguates the two reader ceilings that share kMaxSupportedSchemaVersion (program.h:80, flat_tensor_data_map.h:46).
  • The assertIsNotNone on every match means a rename or reformat fails the test loudly instead of silently disabling the check — exactly the failure mode you'd want to avoid in a guard like this.
  • The relations model the compatibility policy correctly: writer--reader for each family (a runtime may support a version before any writer emits it), and == between the two PTD writers (they stamp the same field of the same file). PTE and PTD kept independent.

Readers — verified placement

  • Program::load (program.cpp:226) — the check sits right after the root-table pointer and before named_data/constant_segment reads, returning InvalidProgram. Correct.
  • FlatTensorDataMap (flat_tensor_data_map.cpp:301) — same shape, returns InvalidExternalData. Correct.
  • <= (not ==), version defaulting to 0 when absent, and both numbers named in the message with an actionable fix — all as described.

Tests

  • The positive/negative pairing per format is the right shape, and the program negative test using Verification::Minimal proves the gate doesn't depend on full verification. The negative fixtures are otherwise-valid files differing only in the version field, so the rejection is attributable to the gate and nothing else.
  • The flat_tensor fixture's reinterpret_cast<const uint32_t*>(flatbuffer) assumes little-endian + aligned start — fine for a test fixture and it mirrors save_ptd(), as previously noted. No change needed.

Minors addressed

  • Both file-identifier comments now say the tag is bumped by convention — matches reality (the identifier isn't code-enforced to bump on a break).
  • The bump rule is now documented next to EXECUTORCH_SCHEMA_VERSION (exir/version.py), which is what gives the number teeth.

One optional follow-up (non-blocking)

As you noted, the guard covers the constants agreeing half. It does not mandate a version bump when a .fbs actually changes shape — that needs schema-shape diffing and is reasonably out of scope. Worth a follow-up issue, along with the Python PTD reader still comparing with != (serialize.py) rather than the <= semantics this PR establishes on the C++ side, so a future non-zero PTD version wouldn't regress the Python path.

Nothing here blocks merge. Nice work turning the cross-reference comments into an enforced check within this same PR.
• branch shoumikhin/program-version-gate

<cstring> and <vector> already arrive through buffer_data_loader.h and the
generated flatbuffers header, so drop them. Replace the per-symbol using
declarations with namespace directives, matching the convention used elsewhere.
CopilotAI review requested due to automatic review settings August 25, 2026 02:07

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@shoumikhin
shoumikhin merged commit d181029 into mainAug 25, 2026
212 of 213 checks passed
@shoumikhin
shoumikhin deleted the shoumikhin/program-version-gate branch August 25, 2026 02:44
shoumikhin added a commit that referenced this pull request Aug 25, 2026
…22117)
### Summary
The Python `FlatTensorSerializer.deserialize` refused any PTD file whose
`version` did not **exactly** equal `_FLAT_TENSOR_VERSION`:
```python
if flat_tensor.version != _FLAT_TENSOR_VERSION:
raise NotImplementedError(...)
```
That rejects an **older** file too, which contradicts:
- the append-only schema policy in `schema/README.md` (older files stay
loadable), and
- the C++ runtime readers, which accept anything `<=` their supported
version and only refuse a file **newer** than they understand
(`Program::load`, `FlatTensorDataMap::load`).
This aligns the Python PTD reader with that policy: compare with `>`
instead of `!=`, so an older or equal file loads and only a newer one is
refused. The error message now says the file is newer than this reader
supports.
### Context
This was called out as a follow-up during review of #22114 (which adds
the `<=` gate on the C++ side). It keeps the Python export/tooling path
consistent so a future non-zero PTD version won't regress it.
### Test plan
Two tests in `extension/flat_tensor/test/test_serialize.py`:
- `test_deserialize_refuses_newer_version` — a bumped-version file is
refused.
- `test_deserialize_accepts_older_version` — a file older than the
reader still loads (the case the old `!=` wrongly rejected).
Both are behavior-only; no schema or format change. Version constants
are unchanged (still `0`), so this is inert for existing files and only
changes behavior once a non-zero version is ever stamped.
shoumikhin added a commit that referenced this pull request Aug 25, 2026
### Summary
`FlatTensorDataMap::load()` reads the flatbuffer root table without
first bounds-checking the root offset:
```cpp
const flat_tensor_flatbuffer::FlatTensor* flat_tensor =
flat_tensor_flatbuffer::GetFlatTensor(flat_tensor_data->data());
// then immediately:
flat_tensor->named_data(); // walks the root vtable
flat_tensor->segments();
```
`GetFlatTensor()` interprets the `uoffset_t` at the start of the buffer
as the root-table offset and returns `buf + offset`. The first field
access then walks that table's vtable. Nothing on this path runs full
flatbuffer verification, so a corrupt or truncated PTD file with a bad
root offset makes these accessors dereference memory **outside the
buffer** (an OOB read, flagged by ASan).
The program loader already guards against exactly this in
`runtime/executor/program.cpp`. This change mirrors that guard for PTD
files.
### What changed
After the existing identifier and alignment checks, before
`GetFlatTensor()`:
- Confirm the buffer is at least a flatbuffer header long (`uoffset_t` +
file identifier).
- Confirm the root offset is `>= kMinBufferSize` and leaves room for a
vtable `soffset_t` within the buffer.
- Return `InvalidExternalData` otherwise (the same error the surrounding
checks use).
FlatTensor is **not** size-prefixed — both writers finish the buffer
plain (`builder.Finish` in C++, default `flatc` in Python) and the
reader uses `GetFlatTensor` (not the size-prefixed variant) — so the
root offset is at byte 0, exactly as in the program case.
### Scope
This is a pre-existing hardening, independent of the schema-version work
in #22114 (the version gate this protects is additive; the OOB path
exists today via `named_data()`/`segments()`).
### Test plan
New `FlatTensorDataMapTest.RejectsOutOfBoundsRootOffset`: copies a valid
PTD into a max-aligned buffer, overwrites only the root offset with a
value past the end (leaving identifier + alignment intact), and asserts
`load()` returns `InvalidExternalData`. Without the check, that same
input is an out-of-bounds vtable read under ASan.
> Note: I was unable to build/run the C++ suite in my local environment,
so I'm relying on this PR's CI (which builds the runtime and runs
`extension/flat_tensor` tests, including under ASan) as the
authoritative check. The change mirrors an existing, tested guard in
`program.cpp`.
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA SignedThis label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed.release notes: runtimeChanges related to the core runtime which loads the program methods, initializes delegates, and runs

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants

@shoumikhin@Gasoonjia@huydhn
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

Refuse a PTE or PTD file whose schema version is newer than the runtime can read - #22114

Merged
shoumikhin merged 8 commits into
mainfrom
shoumikhin/program-version-gate
Aug 25, 2026
Merged

Refuse a PTE or PTD file whose schema version is newer than the runtime can read#22114
shoumikhin merged 8 commits into
mainfrom
shoumikhin/program-version-gate

Conversation

@shoumikhin

@shoumikhinshoumikhin commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

Summary

Every exported PTE file and every PTD data file carries a schema version. The exporters stamp it, and nothing in the runtime ever read it back. The only gate on a file was its four byte identifier, ET12 for a program and FT01 for data. That says "this is an ExecuTorch file" and says nothing about which shape of file it is. A file written by an exporter newer than the runtime was therefore accepted, then misread field by field, and failed later at load or during execution with an error that points somewhere else.

This matters more now that the two halves can come from different builds. The wheel ships prebuilt runtime libraries, so a user can export with one installation of ExecuTorch and run with a runtime that was never built next to it.

This PR reads the version in both readers, right after the root table is obtained, and refuses anything above the highest version the runtime supports. Program::load returns InvalidProgram, FlatTensorDataMap::load returns InvalidExternalData, which is what the checks around each of them return. Both name the two numbers:

Program schema version 1 is newer than the highest this runtime supports (0).
Export the model with an older ExecuTorch, or update the runtime.

The comparison is "less than or equal", not "equal", because the project promises that an older file keeps working on a newer runtime, and the schemas only ever grow by appending optional fields. Only a file from the future is refused.

Each new constant sits next to the class it guards, and points in a comment at the writer constants for the same number: EXECUTORCH_SCHEMA_VERSION for the program, kSchemaVersion and _FLAT_TENSOR_VERSION for the data file. A bump of one without the other is then easy to spot. Nothing writes a version other than zero today, so no file in the wild changes behavior. This is the reader side that a future version bump needs in order to mean anything.

One limit worth stating plainly: a runtime that is already deployed has no check at all, so this protects runtimes built from this change onward. That is inherent to adding a reader.

Test plan

Four new tests, two per format. Each pair builds a minimal file in memory through the real FlatBuffer builder, one stamped at the supported version and one above it. The first is expected to load and the second to be refused. The positive test is the control: without it, a rejection could just as well come from a malformed fixture as from the version. The program test asks for the cheapest verification level, which shows the check does not depend on full verification being enabled. The data test follows the byte layout that save_ptd() writes, with no segments.

Built and ran both suites on Linux x86_64, before and after the change:

cmake -S . -B cmake-out -DEXECUTORCH_BUILD_TESTS=ON \
-DEXECUTORCH_ENABLE_PROGRAM_VERIFICATION=ON \
-DEXECUTORCH_BUILD_EXTENSION_DATA_LOADER=ON \
-DEXECUTORCH_BUILD_EXTENSION_FLAT_TENSOR=ON
cmake --build cmake-out -j
./cmake-out/runtime/executor/test/program_test
./cmake-out/extension/flat_tensor/test/extension_flat_tensor_test

The existing tests pass unchanged in both runs, plus the four new ones. As a control, deleting either check while keeping the tests makes that format's negative test fail, with the newer file loading and reporting no error, so the check is what catches it.

Binary size, measured on the two object files. program.cpp.o grows by 24 bytes with logging disabled and 239 bytes with logging enabled. flat_tensor_data_map.cpp.o grows by 32 bytes with logging disabled and 243 bytes with logging enabled.

…read
Every exported PTE file carries a schema version. The exporter stamps it from
EXECUTORCH_SCHEMA_VERSION, and nothing in the runtime ever read it back. The only gate
on a file was the four byte identifier ET12, which says "this is an ExecuTorch program"
and says nothing about which shape of program it is. A file written by an exporter newer
than the runtime was therefore accepted, then misread field by field, and failed later
at load or during execution with an error that points somewhere else.
This matters more now that the two halves can come from different builds. The wheel
ships prebuilt runtime libraries, so a user can export with one installation of
ExecuTorch and run with a runtime that was never built next to it.
Read the version in Program::load, right after the root table is obtained, and refuse
anything above Program::kMaxSupportedSchemaVersion with InvalidProgram and a message
that names both numbers. The comparison is "less than or equal", not "equal", because
the project promises that an older file keeps working on a newer runtime, and the schema
only ever grows by appending optional fields. Only a file from the future is refused.
The new constant sits next to the class it guards, and the two constants point at each
other in comments so that a bump of one without the other is easy to spot. Nothing
writes a version other than zero today, so no file in the wild changes behavior. This is
the reader side that a future version bump needs in order to mean anything.
Two new tests build a minimal program through the real FlatBuffer builder, one stamped
at the supported version and one above it, and check that the first loads and the second
returns InvalidProgram at the cheapest verification level. Ran the program test suite on
Linux x86_64 before and after the change: the same tests pass in both, plus the two new
ones. Deleting the check and keeping the tests makes the negative test fail, with the
newer file loading successfully, so the check is what catches it. Measured on the object
file, the cost is 24 bytes with logging disabled and 239 bytes with logging enabled.
The companion PTD file has the same unread version field, and this change does not touch
it.
CopilotAI lite review requested due to automatic review settings August 24, 2026 23:14
@pytorch-bot

pytorch-botBot commented Aug 24, 2026

Copy link
Copy Markdown

🔗 Helpful Links

🧪 See artifacts and rendered test results at hud.pytorch.org/pr/pytorch/executorch/22114

Note: Links to docs will display an error until the docs builds have been completed.

❌ 1 New Failure, 2 Unclassified Failures

As of commit 8f613a8 with merge base 1afd07f (image):

NEW FAILURE - The following job has failed:

UNCLASSIFIED FAILURES - DrCI could not classify the following jobs because the workflow did not run on the merge base. The failures may be pre-existing on trunk or introduced by this PR:

This comment was automatically generated by Dr. CI and updates every 15 minutes.

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@meta-clameta-claBot added the CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. label Aug 24, 2026
@shoumikhinshoumikhin added the release notes: runtime Changes related to the core runtime which loads the program methods, initializes delegates, and runs label Aug 24, 2026
CopilotAI review requested due to automatic review settings August 24, 2026 23:19

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

…read
The data file has the same hole the program file had. Both writers, the C++ one in
serialize.h and the Python one in serialize.py, stamp a schema version into every PTD
file, and nothing in the runtime ever read it back. The four byte identifier FT01 was the
only gate, and it says nothing about which shape of data follows.
Read the version in FlatTensorDataMap::load, right after the root table is obtained, and
refuse anything above FlatTensorDataMap::kMaxSupportedSchemaVersion with
InvalidExternalData, which is what the checks around it return. The rule is the same as
for the program file: "less than or equal", so an older data file keeps loading.
Two new tests build a minimal PTD file in memory, one stamped at the supported version
and one above it. The positive one is the control. Without it, a rejection could just as
well come from a malformed fixture as from the version. Ran the data map suite on Linux
x86_64: the six existing tests pass unchanged and both new ones pass. Deleting the check
makes the negative test load the newer file with no error at all, so the check is what
catches it. Measured on the object file, the cost is 32 bytes with logging disabled and
243 bytes with logging enabled.
CopilotAI review requested due to automatic review settings August 24, 2026 23:30

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@shoumikhinshoumikhin changed the title Refuse a PTE file whose schema version is newer than the runtime can readRefuse a PTE or PTD file whose schema version is newer than the runtime can readAug 24, 2026
The comment next to each version check said the four byte identifier "only
says that this is a program" / "FlatTensor data". That undersells it. The
identifier is itself the coarse compatibility gate: ET12 and FT01 are bumped
only on a backward-incompatible schema change, so the identifier selects a
schema family and check_header already rejects a file from a different family
as IncompatibleVersion. The new version scalar is the finer gate within one
family. Reword both comments to say that, so a future reader does not conclude
the identifier carries no version meaning. Comment-only, no behavior change.
CopilotAI review requested due to automatic review settings August 25, 2026 00:29

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@GasoonjiaGasoonjia left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for doing the BC/FC check! I think there're two questions im not sure clear:

  1. When should we update the ExecuTorch version? What's the rule behind it?
  2. Is there any ci to mandatory make the version bump happen?

@Gasoonjia

Copy link
Copy Markdown
Contributor

@claude review the PR

@claude

claudeBot commented Aug 25, 2026

Copy link
Copy Markdown

Claude finished @Gasoonjia's task in 2m 7s —— View job


PR Review: Refuse a PTE/PTD file whose schema version is newer than the runtime can read

I read the full diff plus the surrounding load paths in program.cpp and flat_tensor_data_map.cpp. This is a clean, well-scoped change and the reasoning in the description is sound. Below is my feedback, followed by answers to @Gasoonjia's two questions.

What's good

  • Both checks sit in the right place — immediately after the root table pointer is obtained (program.cpp:234, flat_tensor_data_map.cpp:301) and return the same error the surrounding code already uses (InvalidProgram / InvalidExternalData).
  • <= rather than == is the correct choice given the append-only-optional-fields FC promise, and the error messages name both numbers and tell the user what to do.
  • Because version() defaults to 0 when the field is absent, files written before the field existed still load. And since nothing stamps a non-zero version today, this is inert in the wild — low risk.
  • The positive/negative test pairing is the right shape; the program negative test using Verification::Minimal nicely proves the gate doesn't depend on full verification.

The real gap — nothing enforces the constants stay in sync

This is the same concern behind @Gasoonjia's questions. The correctness of the gate depends on four hand-maintained constants agreeing:

ConstantFile
EXECUTORCH_SCHEMA_VERSIONexir/version.py
Program::kMaxSupportedSchemaVersionruntime/executor/program.h:85
_FLAT_TENSOR_VERSIONextension/flat_tensor/serialize/serialize.py:48
kSchemaVersionextension/flat_tensor/serialize/serialize.h
FlatTensorDataMap::kMaxSupportedSchemaVersionextension/flat_tensor/flat_tensor_data_map.h

Right now the only thing tying them together is the cross-reference comments. If someone bumps the writer (exir/version.py) and forgets the reader (program.h), the failure mode is exactly the silent misread this PR is trying to prevent — the reader would reject files it can and should read (an FC regression), and the comments alone won't catch it in review.

Consider adding a tiny guard so a bump can't drift:

  • A C++ static_assert(Program::kMaxSupportedSchemaVersion == kSchemaVersion ...) where the headers are both visible, and/or
  • A Python unit test that reads both .h files (or a generated constant) and asserts equality with EXECUTORCH_SCHEMA_VERSION / _FLAT_TENSOR_VERSION.

That would answer question 2 directly and make the comments enforceable rather than advisory.

Minor

  • Reading version() before full verification. In Verification::Minimal mode only root_offset is bounds-checked (program.cpp:216); flatbuffer_program->version() then dereferences the vtable of an otherwise-unverified buffer. This is consistent with the existing code (named_data(), constant_segment() are read the same way right after), so it's not a regression — just noting the version read inherits the same trust assumptions as everything below it. No change needed.
  • Comment claim about the file identifier. The comments state the 4-byte identifier "changes only on a backward-incompatible schema change." That's the intended contract, but I don't see anything in the schema or tooling that enforces the identifier bumps on a breaking change — it's a convention. Fine to keep, just be aware it's an assertion of intent, not a guarantee the code makes.
  • reinterpret_cast<const uint32_t*>(flatbuffer) in the flat_tensor test assumes little-endian + aligned start. Acceptable for a test fixture, and it mirrors save_ptd(), but it's the fragile part of the suite if the header layout ever changes.

Answers to @Gasoonjia

  1. When should the version bump? The schemas are append-only: adding an optional field is backward- and forward-compatible and does not require a bump (that's the whole point of the <= check). A bump is only meaningful when a change makes a new file unreadable-as-old by an old runtime — i.e. a semantic change to an existing field, or a new field the runtime must understand to execute correctly. Purely additive optional fields shouldn't bump; changes that old runtimes would silently misinterpret should. It would be worth stating this rule explicitly in a comment next to EXECUTORCH_SCHEMA_VERSION, since this PR is what gives the number teeth.
  2. Is there CI to mandate the bump? Not that I can find, and this PR doesn't add one. There's no check that a schema (.fbs) change forces a version bump, nor that the writer/reader constants match. The sync-assert / test suggested above covers the second half (constants agreeing); mandating a bump on .fbs changes is harder and probably out of scope here, but worth a follow-up issue.

Overall: correct and low-risk as written. The one thing I'd push for before merge is a mechanical guard that the writer and reader constants can't diverge — otherwise the whole mechanism is one forgotten edit away from silently regressing FC.
• branch shoumikhin/program-version-gate

CopilotAI review requested due to automatic review settings August 25, 2026 01:16

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

The version gate this PR adds is only correct while five hand-maintained
constants agree: the PTE writer (EXECUTORCH_SCHEMA_VERSION) and its runtime
reader (Program::kMaxSupportedSchemaVersion), and the two PTD writers
(_FLAT_TENSOR_VERSION, kSchemaVersion) and their reader
(FlatTensorDataMap::kMaxSupportedSchemaVersion). Until now only cross-reference
comments tied them together, so bumping a writer without its reader would make a
runtime refuse files it should read, and bumping one PTD writer without the
other would let a file be stamped below its real layout and misread.
Extend schema/test/test_schema.py, which already enforces cross-file schema
sync and runs in OSS CI with no build wiring, with a check that parses the five
literals as text and asserts the compatibility relationship: each writer must be
<= its reader ceiling (a reader may support a version before any writer emits
it, but never the reverse), and the two PTD writers must be exactly equal
because they stamp the same field of the same file. The parse keys on file path,
not symbol, since the two reader ceilings share a name, and fails closed if a
constant can no longer be found so a reformat can't silently disable the guard.
Also state the bump rule next to EXECUTORCH_SCHEMA_VERSION and soften the
file-identifier comments: the identifier is bumped by convention on a breaking
change, not by anything the code enforces.
CopilotAI review requested due to automatic review settings August 25, 2026 01:22

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@shoumikhin

Copy link
Copy Markdown
ContributorAuthor

Thanks @claude — good catch on the drift risk. I added the guard, and it's the right call to do it in this PR since this is what turns the constants into a real gate.

What I added: a check in schema/test/test_schema.py (the file that already enforces cross-file schema sync and runs in OSS CI, so no new build wiring). It reads all five version constants as text and asserts:

  • PTE: EXECUTORCH_SCHEMA_VERSIONProgram::kMaxSupportedSchemaVersion
  • PTD: _FLAT_TENSOR_VERSION == kSchemaVersionFlatTensorDataMap::kMaxSupportedSchemaVersion

The relationship is writer-≤-reader, not strict equality: a runtime is allowed to support a version before any writer emits it (forward-compat), but a writer must never stamp a version its reader would refuse. The two PTD writers are compared with == because they stamp the same field of the same file. The parse keys on the file path (the two reader ceilings share the name kMaxSupportedSchemaVersion) and fails if a constant can't be found, so a rename/reformat can't silently disable the check.

I verified it locally: passes today, fails when I bump one writer without its reader, fails when the two PTD writers disagree, and still passes when only a reader is bumped ahead (the legitimate case).

On the minors:

  • Softened both file-identifier comments to say the tag is bumped by convention on a breaking change — you're right it's not code-enforced.
  • Added the bump rule you described next to EXECUTORCH_SCHEMA_VERSION.
  • Left the version()-before-verification read and the test's reinterpret_cast as-is, since you flagged both as no-change-needed (the version read inherits the same trust as the reads around it; the cast mirrors save_ptd()).

On mandating a bump when a .fbs changes: still out of scope here (it needs schema-shape diffing), but the constant-sync half is now covered. Happy to file a follow-up for the .fbs-change mandate and for the Python PTD reader still using != (separate small PR).

@shoumikhin

Copy link
Copy Markdown
ContributorAuthor

@claude review the PR

@claude

claudeBot commented Aug 25, 2026

Copy link
Copy Markdown

Claude finished @shoumikhin's task in 1m 32s —— View job


Re: the drift guard (8f613a8)

I read the new test_schema.py::test_schema_version_constants_in_sync and traced all five regexes against the actual declarations. This resolves the concern from my earlier review — the guard is correct and lands in the right place.

Verified the five patterns match today's source:

ConstantDeclarationRegex
EXECUTORCH_SCHEMA_VERSION= 0 (exir/version.py:14)
Program::kMaxSupportedSchemaVersion= 0 (program.h:85)
_FLAT_TENSOR_VERSION: int = 0 (serialize.py:49)
kSchemaVersion= 0 (serialize.h:26)
FlatTensorDataMap::kMaxSupportedSchemaVersion= 0 (flat_tensor_data_map.h:47)

The design decisions are right:

  • Keying on file path, not symbol name is the correct call — the two reader ceilings both spell kMaxSupportedSchemaVersion, so a symbol-based parse would be ambiguous. Path-keyed re.search on each file avoids that, and since kMaxSupportedSchemaVersion doesn't contain the literal substring kSchemaVersion, the PTD-writer regex in serialize.h can't accidentally match a ceiling either.
  • Writer <= reader, PTD writers == matches the compatibility policy exactly: a runtime may support a version before any writer emits it (FC), but a writer must never stamp a version its reader refuses; and the two PTD writers stamp the same field, so they must be equal. The asymmetry is intentional and correctly encoded.
  • Fail-closed on a missing constant (assertIsNotNone with a message that tells the next editor to fix the test) is what makes a rename/reformat surface loudly instead of silently disabling the guard — this was the exact failure mode I was worried about.
  • Putting it in test_schema.py (already in OSS CI, no new build wiring) is the pragmatic choice over a static_assert, which couldn't reach the Python writers anyway.

On the minors — all handled as expected: the file-identifier comments now say "by convention," the bump rule is stated next to EXECUTORCH_SCHEMA_VERSION, and leaving the version()-before-verification read and the test reinterpret_cast as-is is fine.

One small note (non-blocking): the guard proves the five constants agree with each other, but the writer/reader ceiling and the actual serialized field are still tied only by the runtime code that reads version(). That's fine — that path is what your four new C++ tests exercise, so the two halves (constants-agree here, field-is-gated there) are each covered.

On follow-ups: filing separate issues for (a) mandating a version bump on .fbs shape changes and (b) the Python PTD reader still using != instead of <= sounds right — both are genuinely out of scope for this PR, and the constant-sync half was the load-bearing gap.

I couldn't run the suite here (sandbox blocked python), but the parse is simple text matching and I verified each pattern against its literal by hand; your local run (passes today, fails on each induced drift) covers the dynamic behavior.

LGTM.
• branch shoumikhin/program-version-gate

@claude

claudeBot commented Aug 25, 2026

Copy link
Copy Markdown

Claude finished @shoumikhin's task in 2m 4s —— View job


Re-review: Refuse a PTE/PTD file whose schema version is newer than the runtime can read

I re-read the full diff against origin/main, focused on the commit added since my last pass (the constant-sync guard) plus a fresh look at the readers and tests. My earlier concern — that the whole mechanism was one forgotten edit away from silently regressing FC — is now addressed. Summary: correct, low-risk, and I'd merge it.

The drift guard resolves the main gap

schema/test/test_schema.py::test_schema_version_constants_in_sync is the right fix in the right place — it lives in the file that already enforces cross-file schema sync and runs in OSS CI, so no new build wiring. Reviewing the logic:

  • Text-parse over import is the correct call here (importing the writers drags in torch; the C++ constants have no Python binding), and keying the parse on file path rather than symbol name correctly disambiguates the two reader ceilings that share kMaxSupportedSchemaVersion (program.h:80, flat_tensor_data_map.h:46).
  • The assertIsNotNone on every match means a rename or reformat fails the test loudly instead of silently disabling the check — exactly the failure mode you'd want to avoid in a guard like this.
  • The relations model the compatibility policy correctly: writer--reader for each family (a runtime may support a version before any writer emits it), and == between the two PTD writers (they stamp the same field of the same file). PTE and PTD kept independent.

Readers — verified placement

  • Program::load (program.cpp:226) — the check sits right after the root-table pointer and before named_data/constant_segment reads, returning InvalidProgram. Correct.
  • FlatTensorDataMap (flat_tensor_data_map.cpp:301) — same shape, returns InvalidExternalData. Correct.
  • <= (not ==), version defaulting to 0 when absent, and both numbers named in the message with an actionable fix — all as described.

Tests

  • The positive/negative pairing per format is the right shape, and the program negative test using Verification::Minimal proves the gate doesn't depend on full verification. The negative fixtures are otherwise-valid files differing only in the version field, so the rejection is attributable to the gate and nothing else.
  • The flat_tensor fixture's reinterpret_cast<const uint32_t*>(flatbuffer) assumes little-endian + aligned start — fine for a test fixture and it mirrors save_ptd(), as previously noted. No change needed.

Minors addressed

  • Both file-identifier comments now say the tag is bumped by convention — matches reality (the identifier isn't code-enforced to bump on a break).
  • The bump rule is now documented next to EXECUTORCH_SCHEMA_VERSION (exir/version.py), which is what gives the number teeth.

One optional follow-up (non-blocking)

As you noted, the guard covers the constants agreeing half. It does not mandate a version bump when a .fbs actually changes shape — that needs schema-shape diffing and is reasonably out of scope. Worth a follow-up issue, along with the Python PTD reader still comparing with != (serialize.py) rather than the <= semantics this PR establishes on the C++ side, so a future non-zero PTD version wouldn't regress the Python path.

Nothing here blocks merge. Nice work turning the cross-reference comments into an enforced check within this same PR.
• branch shoumikhin/program-version-gate

<cstring> and <vector> already arrive through buffer_data_loader.h and the
generated flatbuffers header, so drop them. Replace the per-symbol using
declarations with namespace directives, matching the convention used elsewhere.
CopilotAI review requested due to automatic review settings August 25, 2026 02:07

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@shoumikhin
shoumikhin merged commit d181029 into mainAug 25, 2026
212 of 213 checks passed
@shoumikhin
shoumikhin deleted the shoumikhin/program-version-gate branch August 25, 2026 02:44
shoumikhin added a commit that referenced this pull request Aug 25, 2026
…22117)
### Summary
The Python `FlatTensorSerializer.deserialize` refused any PTD file whose
`version` did not **exactly** equal `_FLAT_TENSOR_VERSION`:
```python
if flat_tensor.version != _FLAT_TENSOR_VERSION:
raise NotImplementedError(...)
```
That rejects an **older** file too, which contradicts:
- the append-only schema policy in `schema/README.md` (older files stay
loadable), and
- the C++ runtime readers, which accept anything `<=` their supported
version and only refuse a file **newer** than they understand
(`Program::load`, `FlatTensorDataMap::load`).
This aligns the Python PTD reader with that policy: compare with `>`
instead of `!=`, so an older or equal file loads and only a newer one is
refused. The error message now says the file is newer than this reader
supports.
### Context
This was called out as a follow-up during review of #22114 (which adds
the `<=` gate on the C++ side). It keeps the Python export/tooling path
consistent so a future non-zero PTD version won't regress it.
### Test plan
Two tests in `extension/flat_tensor/test/test_serialize.py`:
- `test_deserialize_refuses_newer_version` — a bumped-version file is
refused.
- `test_deserialize_accepts_older_version` — a file older than the
reader still loads (the case the old `!=` wrongly rejected).
Both are behavior-only; no schema or format change. Version constants
are unchanged (still `0`), so this is inert for existing files and only
changes behavior once a non-zero version is ever stamped.
shoumikhin added a commit that referenced this pull request Aug 25, 2026
### Summary
`FlatTensorDataMap::load()` reads the flatbuffer root table without
first bounds-checking the root offset:
```cpp
const flat_tensor_flatbuffer::FlatTensor* flat_tensor =
flat_tensor_flatbuffer::GetFlatTensor(flat_tensor_data->data());
// then immediately:
flat_tensor->named_data(); // walks the root vtable
flat_tensor->segments();
```
`GetFlatTensor()` interprets the `uoffset_t` at the start of the buffer
as the root-table offset and returns `buf + offset`. The first field
access then walks that table's vtable. Nothing on this path runs full
flatbuffer verification, so a corrupt or truncated PTD file with a bad
root offset makes these accessors dereference memory **outside the
buffer** (an OOB read, flagged by ASan).
The program loader already guards against exactly this in
`runtime/executor/program.cpp`. This change mirrors that guard for PTD
files.
### What changed
After the existing identifier and alignment checks, before
`GetFlatTensor()`:
- Confirm the buffer is at least a flatbuffer header long (`uoffset_t` +
file identifier).
- Confirm the root offset is `>= kMinBufferSize` and leaves room for a
vtable `soffset_t` within the buffer.
- Return `InvalidExternalData` otherwise (the same error the surrounding
checks use).
FlatTensor is **not** size-prefixed — both writers finish the buffer
plain (`builder.Finish` in C++, default `flatc` in Python) and the
reader uses `GetFlatTensor` (not the size-prefixed variant) — so the
root offset is at byte 0, exactly as in the program case.
### Scope
This is a pre-existing hardening, independent of the schema-version work
in #22114 (the version gate this protects is additive; the OOB path
exists today via `named_data()`/`segments()`).
### Test plan
New `FlatTensorDataMapTest.RejectsOutOfBoundsRootOffset`: copies a valid
PTD into a max-aligned buffer, overwrites only the root offset with a
value past the end (leaving identifier + alignment intact), and asserts
`load()` returns `InvalidExternalData`. Without the check, that same
input is an out-of-bounds vtable read under ASan.
> Note: I was unable to build/run the C++ suite in my local environment,
so I'm relying on this PR's CI (which builds the runtime and runs
`extension/flat_tensor` tests, including under ASan) as the
authoritative check. The change mirrors an existing, tested guard in
`program.cpp`.
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA SignedThis label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed.release notes: runtimeChanges related to the core runtime which loads the program methods, initializes delegates, and runs

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants

@shoumikhin@Gasoonjia@huydhn