Skip to content

Fix: check the parts of a buffer descriptor that were taken on trust - #1704

Merged
ChaoWao merged 4 commits into
hw-native-sys:mainfrom
ChaoWao:p1a-close-merge-gates
Aug 6, 2026
Merged

Fix: check the parts of a buffer descriptor that were taken on trust#1704
ChaoWao merged 4 commits into
hw-native-sys:mainfrom
ChaoWao:p1a-close-merge-gates

Conversation

@ChaoWao

@ChaoWao ChaoWao commented Aug 5, 2026

Copy link
Copy Markdown
Collaborator

Three places a buffer descriptor was taken on trust

The wire ABI froze its layout, and validate_buffer_descriptor is the gate every receive boundary
runs. Three things it was documented to establish, it did not check. No field, offset, or enum
value changes
— every static_assert in buffer.h is untouched, so this is a tightening of what
the existing bytes are allowed to say.

A cached mapping was returned without looking at the descriptor that asked for it

Identity says WHICH allocation, not how big it is or how to reach it. A second descriptor carrying
one identity and a different nbytes / access / backend_kind / body describes something the
existing mapping is not, and the caller got that mapping with no sign anything differed:

before:  materialize(id=X, nbytes=4096)  -> base B
         materialize(id=X, nbytes=8192)  -> base B      # silently, under a size nothing backs
after:   materialize(id=X, nbytes=8192)  -> ValueError: ... already materialized from a different
                                                        descriptor (nbytes: 4096 -> 8192)

The mapping already handed out wins — callers may hold addresses into it — so a conflict is refused
rather than resolved by replacing it. Map-once stays a cache; it is not a trust boundary.

A POSIX shm object was mapped without checking it is as large as claimed

Every view bound check is byte_offset + extent <= nbytes. An unverified nbytes makes all of them
comparisons against a number no memory stands behind — including the overflow-safe ones #1703 just
landed. The object's real size is the one quantity here an owner cannot overstate:

128-byte object, descriptor claims 1 MiB  ->  ValueError: shm object 'psm_…' is 128 bytes, short of
                                                          the 1048576 its descriptor claims

A body was accepted whatever the backend that reads it says it should be

body_len was bounded and nothing else. But backend_kind is what selects how a consumer reads
body, so a body that does not fit that reading is not a smaller backing — it is a different value.
The sharp case is an address: a DEVICE_MALLOC body of three bytes reads as a truncated pointer,
indistinguishable from a real one, which materialize then hands out as a base.

backend body
FORK_SHM / FORK_COW / DEVICE_MALLOC / VMM_WINDOW exactly 8 bytes, non-zero base — nothing is mapped or allocated at 0, so a zero body is an unfilled one
POSIX_SHM 1–32 bytes of printable ASCII, no /
REMOTE_SIDECAR empty — its authoritative descriptor rides in the per-task sidecar

The POSIX_SHM charset is what makes one field stop having two schemas: the native validator
accepted any non-NUL bytes while the Python side decodes the same field as UTF-8 before the open, so
b"\xff" passed here and failed there. Printable ASCII is a strict UTF-8 subset, so the decode is
total by construction rather than by a second check that could drift. / is excluded because
shm_open forbids it inside a name; space and the control range because the name is rendered into
paths and diagnostics.

Bytes a producer chose not to fill must be zero — the tail of body past body_len, and a
Tensor's shape/stride slots past ndims. Both cross the process boundary with the struct (the blob
memcpy carries all 144 bytes regardless), so whatever the owner's memory held there would cross with
it. The _pad fields are deliberately not covered, and the comment says why rather than leaving it
to be inferred: they are alignment slack no producer selects, and CanonicalIdentity's is tolerated
on purpose so two decodes of one backing still key alike.

I checked the construction path already zeroes those regions before tightening this, so nothing that
builds a descriptor today is affected. Two test fixtures were: both built a POSIX_SHM descriptor
with no body at all, a shape no real system produces.

The 256-tensor bound was asserted against the wrong element

MAILBOX_ARGS_CAPACITY is checked against ChipTensor. Tensor is 144 B where ChipTensor is
128 B, so the wire cutover swaps the blob's element without touching either cap, and the region only
has to grow by 16 B × 256 for a full frame to overflow — with the descriptor size frozen, that is a
frame-size problem found by the first 256-tensor task. Asserting the wire element makes the pair fail
the build instead. It holds with room to spare: 37896 B of a 64016 B region.

One unrelated fix, riding along

simpler-remote-worker accepts only the host_tcp transport (#1011). #1688 narrowed the remote unit
tests it knew about onto that profile; #1692 landed test_remote_zero_residual.py in the same window
still asking for sim. Two independently green PRs, red after the merge — every case in that file
failed its own setup, so the rollback each one exists to observe never ran.

It is one line, it touches nothing near buffers, and it is what keeps ut red on every PR in flight,
so it is here as its own commit. Say the word and I will split it out.

Verification

  • cpput test_buffer18/18 (3 new: body schema per backend, non-zero reserved tail,
    non-zero slots past ndims)
  • pyut full suite — 1149 passed, 13 skipped, 0 failed (the 3 remote-transport failures go
    green with the fix above)
  • Both import guards demonstrated firing against a live registry, not only asserted in tests
  • Reserved-zero verified on the construction path before tightening it
  • The wire-element assert compiles and holds; it fails the build if either size drifts
  • Build green; clang-format / ruff check / ruff format clean; no added line over 120 chars
  • Hardware (onboard) — no runtime dispatch path is touched

Not here

  • The endpoint × address_space matrix. materialize still resolves a DEVICE backing for a host
    endpoint. That is a behavioural gate needing owner/registry context, with its own test surface.
  • Minting owner_instance_id after fork/adoption, and the release-path ordering (a mapping-close
    failure must not skip the owner unlink; a released handle must not keep deriving Tensors). Both
    are owner-authority and lifetime changes; doing them here would blur where this change ends.
  • No SIMPLER_BUILD_COMMIT change. buffer.h justifies having no wire version field by citing
    that guard, so I checked it exists rather than assuming: python/simpler/task_interface.py:95-133
    compares the extension's stamp against git rev-parse HEAD and raises. It caught me during this
    work, after a reset --hard moved HEAD. The comment is accurate as written.

🤖 Generated with Claude Code

@coderabbitai

coderabbitai Bot commented Aug 5, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Changes

Buffer integrity

Layer / File(s) Summary
Backend descriptor schemas and validation tests
src/common/task_interface/buffer.h, tests/ut/cpp/types/test_buffer.cpp
Descriptor bodies now follow backend-specific schemas. Reserved tail bytes must be zero. C++ tests cover valid and malformed bodies.
Import registry consistency and shared-memory sizing
python/simpler/buffer.py, tests/ut/py/test_buffer.py
Imported buffers retain source descriptors. Conflicting cache descriptors and undersized shared-memory objects are rejected.
Mailbox wire capacity assertion
src/common/hierarchical/worker_manager.h
The worker manager asserts capacity for wire tensors and scalar arguments.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Sequence Diagram(s)

sequenceDiagram
  participant ImportRegistry
  participant POSIXSharedMemory
  participant ImportedBuffer
  ImportRegistry->>ImportRegistry: Check descriptor for identity
  ImportRegistry->>POSIXSharedMemory: Open and inspect object size
  POSIXSharedMemory-->>ImportRegistry: Return actual size
  ImportRegistry->>ImportedBuffer: Store mapping and source descriptor
Loading

Possibly related PRs

Poem

A rabbit checks each buffer byte,
And guards the names both day and night.
Descriptors match, sizes align,
Reserved tails stay clear and fine.
“No stale maps!” the rabbit sings,
While mailbox safety spreads its wings.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly summarizes the main change: stricter validation of buffer descriptor fields and imported buffer assumptions.
Description check ✅ Passed The description directly explains the descriptor validation, shared-memory checks, mailbox assertion, tests, and stated scope.

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@python/simpler/buffer.py`:
- Around line 449-451: Replace the Unicode multiplication character in the
docstring near the endpoint and address_space description with an equivalent
ASCII expression, preserving the docstring’s meaning and surrounding text.

In `@src/common/task_interface/buffer.h`:
- Around line 330-336: Define a single POSIX_SHM name encoding in the
BufferDescriptor schema: update the validator in BufferDescriptor handling for
BackendKind::POSIX_SHM to reject bodies that are not valid UTF-8 in addition to
embedded NULs, and keep Python’s buffer materialization aligned by validating
the same rule before desc.body.decode in python/simpler/buffer.py and raising
the descriptor-validation error type. Add coverage for the malformed UTF-8 case
in both the C++ tests around test_buffer.cpp and the Python construction tests
in test_buffer.py so the native and Python paths enforce the same contract.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 018d3d7c-2b60-4070-908c-8f555d2d8725

📥 Commits

Reviewing files that changed from the base of the PR and between 02f90ef and 3619995.

📒 Files selected for processing (5)
  • python/simpler/buffer.py
  • src/common/hierarchical/worker_manager.h
  • src/common/task_interface/buffer.h
  • tests/ut/cpp/types/test_buffer.cpp
  • tests/ut/py/test_buffer.py

Comment thread python/simpler/buffer.py
Comment thread src/common/task_interface/buffer.h Outdated
ChaoWao added 4 commits August 5, 2026 18:21
Two ways `ImportRegistry.materialize` accepted a backing it had not verified.

A cache hit returned the stored `ImportedBuffer` without looking at the
descriptor that asked for it. Identity says WHICH allocation, not how big it is
or how to reach it, so a second descriptor carrying one identity and a different
nbytes / access / backend_kind / body describes something the existing mapping is
not — and the caller got that mapping with no sign anything differed. The hit now
compares field-wise and refuses a mismatch. The mapping already handed out wins:
callers may hold addresses into it, so a conflict is refused rather than resolved
by replacing it.

The POSIX_SHM branch mapped by name without checking the object is as large as
the descriptor claims. Every view bound check is `byte_offset + extent <=
nbytes`, so an unverified nbytes makes all of them comparisons against a number
no memory stands behind — including the overflow-safe ones. The object's real
size is the one quantity here an owner cannot overstate, and it is now compared
before the mapping is cached.

`ImportedBuffer` keeps the descriptor it was built from, which is what makes the
first check possible. Map-once stays a cache; it is not a trust boundary.

The conflict error names only the fields that differ, as `name: old -> new`. Two
full reprs would carry a 32-byte body twice for what is usually a one-field
disagreement, leaving the reader to diff them by eye.
`validate_buffer_descriptor` bounded `body_len` against DESC_MAX_BYTES and
stopped there, so the body's contents were never checked against the backend that
says how to read them. `backend_kind` is what selects the reading, so a body that
does not fit it is not a smaller backing — it is a different value. The sharp
case is an address: a DEVICE_MALLOC body of three bytes reads as a truncated
pointer, with nothing about it to distinguish it from a real one, and
`materialize` then hands that out as a base.

The four address-bearing backends now require exactly 8 bytes and a non-zero
base, since nothing is mapped or allocated at 0 and a zero body is an unfilled
one. POSIX_SHM requires a non-empty name with no embedded NUL — the name reaches
a C API that stops at the NUL, so two distinct names could otherwise open one
object. REMOTE_SIDECAR requires an empty body, its authoritative descriptor
riding in the per-task sidecar rather than here.

Reserved bytes past `body_len` must be zero. That tail crosses a process
boundary with the descriptor, so whatever the owner's memory held there crosses
with it. It is distinct from the struct's `_pad` fields, which equality and
hashing ignore on purpose so two decodes of one backing key alike: this is a
length-delimited payload's reserved region, not alignment slack. The construction
path already zeroes it (verified against the bound constructor's value-init +
memcpy), so nothing that builds a descriptor today is affected.

The test fixture built a POSIX_SHM descriptor with no body at all, which the
schema correctly refuses; it carries a name now, and a device-backed variant
covers the address form.

A POSIX_SHM name is restricted to printable ASCII other than '/'. The native
validator alone would accept any non-NUL bytes while the Python side decodes the
name as UTF-8 before the open, so `b"\xff"` passed here and failed there — one
field with two schemas. Printable ASCII is a UTF-8 subset, which makes that
decode total; '/' is excluded because shm_open forbids it inside a name, and
space and the control range because the name is rendered into paths and logs.

The reserved-must-be-zero rule extends to a Tensor's shape/stride slots past
`ndims`. They are the same case as the body tail — a producer chose not to fill
them, and the blob memcpy carries all 144 bytes across the boundary regardless —
so leaving them out would make the rule look general while covering one field.
The `_pad` fields stay excluded, and the comment now says why: they are
alignment slack rather than a slot anyone selects, and `CanonicalIdentity`'s is
deliberately tolerated so two decodes of one backing key alike.

`docs/buffer-abi.md` is a published page whose Backends table described what
each backend materializes to but not what its body may contain, which is now
enforced. The table gains that column, and the two consequences a user can hit
— the zero-reserved rule and the shm size check at import — are stated under it.
The existing bound is asserted against `ChipTensor`, the element the blob carries
today. `Tensor` is 144 B where `ChipTensor` is 128 B, so the wire cutover swaps
the element without touching either cap and the region only has to grow by
16 B x 256 for a full frame to overflow — with the descriptor size frozen, that
would be a frame-size problem discovered by the first 256-tensor task.

Asserting the wire element too makes the pair fail the build instead. It holds
with room to spare today: 37896 B of a 64016 B region.
`simpler-remote-worker` rejects anything but `host_tcp` (hw-native-sys#1011), and hw-native-sys#1688
narrowed the unit tests it knew about onto that profile. The zero-residual
acceptance tests landed from hw-native-sys#1692 in the same window still asked for `sim`, so
the merge of two independently green PRs left every case in that file failing
its own setup with `only host_tcp transport is accepted by simpler-remote-worker`
— the rollback each one exists to observe never ran.

Unrelated to the Buffer ABI; it rides here because it is one line and it is what
keeps `ut` red on every PR that touches nothing near it.
@ChaoWao
ChaoWao force-pushed the p1a-close-merge-gates branch from 3619995 to 9c1784a Compare August 6, 2026 01:25
@ChaoWao

ChaoWao commented Aug 6, 2026

Copy link
Copy Markdown
Collaborator Author

Both inline threads addressed and resolved. Three more items came from a maintainer review off-thread, folded into the same commits:

The reserved-must-be-zero rule now covers Tensor too. The justification I wrote for the body tail — "that tail crosses a process boundary with the descriptor, so whatever the owner's memory held there crosses with it" — applies verbatim to the shape/stride slots past ndims, since the blob memcpy carries all 144 bytes regardless. Leaving them out would have made the rule read as general while covering one field. Confirmed the gap first: dirtied inactive slots were ACCEPTED, and two logically identical views differed under a raw 144-byte memcmp. The _pad fields stay excluded and the comment now says why, rather than leaving a reader to infer it: they are alignment slack no producer selects, and CanonicalIdentity's is deliberately tolerated so two decodes of one backing key alike.

docs/buffer-abi.md documents the body rule. It is in mkdocs and its Backends table said what each backend materializes to but nothing about what its body may contain — a user hitting must be exactly 8 bytes had no rule to consult. The table gains that column, plus the two consequences reachable from user code (zero reserved bytes, and the shm size check at import).

The G3 conflict error names only the differing fields (nbytes: 4096 -> 8192) instead of embedding two full reprs with a 32-byte body each.


CI. ut was red on this PR for a reason that is not this PR: test_remote_zero_residual.py asked for transport="sim", which simpler-remote-worker stopped accepting in #1688 — that PR narrowed the remote tests it knew about, and #1692 landed this file with the old profile in the same window. Two independently green PRs, red after the merge. It is one line and it keeps ut red on every PR that touches nothing near it, so it rides here as its own commit, clearly separable. Full pyut is now 1149 passed, 0 failed locally.

st-onboard-a5 also failed. That job fails identically on unrelated PRs right now — perf/per-worker-device-op-locks shows simpler_init failed with code 107001 across 37 cases, 9 passed. This PR's own a5 log has already expired, so I will confirm the same signature on the re-run rather than assume it.

@ChaoWao ChaoWao changed the title Fix: close the remaining P1-A gates on the Buffer wire ABI Fix: check the parts of a buffer descriptor that were taken on trust Aug 6, 2026
@ChaoWao
ChaoWao merged commit 4d0de8e into hw-native-sys:main Aug 6, 2026
51 of 67 checks passed
@ChaoWao
ChaoWao deleted the p1a-close-merge-gates branch August 6, 2026 02:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant