Skip to content

GC deliveries to remotes carry rrefs in the sender's frame, so the receiver mints a phantom object #1017

Description

@grypez

Raising this as partially confirmed. The receiver-side behaviour is demonstrated by a failing test against real stores. The link-level consequence is read from code and not executed, so treat the second half as a hypothesis.

The convention

An rref's +/- is a relative orientation, read in the frame of whichever endpoint receives it. translateRefKtoE flips it on the way out for remote endpoints (store/methods/translators.ts:85-107, and the comment there explains why). Two traversals of a link compose to the identity; one does not. So the invariant is that each hop applies the flip exactly once — it doesn't matter which side does it, only that the count per hop is odd.

Observed

Three tests against real kernel stores, one failing:

✓ flips polarity when naming a kref to a remote        (stored ro-1 -> sent ro+1)
× names a kref the same way in a gc action as in any other delivery
    AssertionError: expected [ 'ro-1' ] to strictly equal [ 'ro+1' ]
✓ mints a second kernel object for a gc action that arrives unflipped

The third test needs two stores — a round trip within one store is not an identity for an rref, so a single-store test cannot express this. It shows that feeding a receiver a sender-framed rref:

  • returns a different kref, owned by the peer endpoint rather than the original owner
  • leaves the real object completely untouched: { reachable: 1, recognizable: 1 }, flag still set. So a cross-link drop or retire has no effect on the object it was about.
  • is stable on repeat — the same phantom is found again, so this is a quiet leak rather than escalating corruption
  • in the export direction the phantom is born flagged reachable, so performRetireImports throws retireImports but ko2 is still reachable. That assertion runs the real handler from garbage-collection/gc-handlers.ts, not a stand-in.

Zero flips per hop, checked on both sides

Send: deliverDropExports / deliverRetireExports / deliverRetireImports (remotes/kernel/RemoteHandle.ts:686-719) put erefs into params verbatim. #sendRemoteCommand handles location hints, capacity and seq numbering — no ref translation.

Receive: #handleRemoteDeliver (:836-849) passes them unchanged to #dropExports / #retireExports / #retireImports, each of which calls a bare translateRefEtoK (:772, :784, :796).

Compare the message case in the same switch: translateMessageKtoE outbound (one flip), translateMessageEtoK inbound (none). One flip per hop. notify likewise. GC is the only delivery kind with zero, and nothing downstream compensates.

Note there is a real inversion documented for the GC path at RemoteHandle.ts:741-763 — receiving dropExports runs dropImports logic, and so on. That one is semantic, not polarity. It's easy to read that table as "direction is already handled here"; these are two independent inversions and only one of them is applied.

Pre-existing

krefsToExistingErefs didn't flip either. #1010 renames it to krefsToErefs and, if anything, improves the surrounding handling: before #1010 a remote GC delivery throw escaped and killed the run loop, whereas the new isVatId catch logs and survives. Filing separately for that reason.

What I have not verified

Everything above is store-level and handler-level. The link-level chain — receiver rolls back and rethrows, seq never advances, sender retransmits, ACK timeout × MAX_RETRIES#onGiveUp — is read from code and not executed. Whether the sender sees a throw, a timeout, or nothing at all, I don't know. A genuine two-kernel test belongs in kernel-test's remote-comms suite; the two stores in my test are wired by hand, passing the introduced rref across directly rather than over a link.

Where a fix would go, which is not obvious

krefsToErefs is also used for vat endpoints, where not flipping is correct (isRemoteId guard at translators.ts:85). Flipping inside it unconditionally would break vats. So the flip belongs at the remote delivery boundary, or in a remote-aware variant — and my failing assertion should probably be restated as isRemoteId-conditional before it is treated as the contract.

Worth deciding separately: should a GC delivery ever be allowed to allocate? translateRefEtoK mints unconditionally (translators.ts:161-165), and a GC action naming a ref the receiver doesn't hold is arguably a no-op rather than an introduction.

Found while reviewing #1010.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions