-
Notifications
You must be signed in to change notification settings - Fork 207
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
marshal: add context argument to valToSlot/slotToVal #2171
Comments
Alternate ApproachesIn reviewing this writeup, I'm now wondering if it's possible that we could implement this entirely by scanning the During For this latter category, we need to not add a
Similarly, we could scan the Let's keep discussing this, but hold off on implementing the |
cc @erights |
The alternate approach outlined seems more complicated to me, because it relies on inference rather than direct observation of what was done. It buys avoidance of modification to the |
What is the Problem Being Solved?
As @FUDCo is working on retiring the c-list entries for resolved promises (#1124 and PR #2110), we identified an enhancement to
marshal
that could make the liveslots code cleaner and less error-prone. The basic need is for a context argument, passed intom.serialize(value, context)
and made available to each call toslot = valToSlot(val, context)
. Likewise,m.unserialize(data, context)
andval = slotToVal(slot, context)
. Our use case would be better served by a separate context object for each act of serialization/unserialization than by having a single context object for the marshaller as a whole.Our most concise example that motivates the problem is a pair of recursively-resolved promises:
(there are minor differences depending upon wheter
foo()
is serialized before or after pA/pB's.then
callbacks are fired, but the same problem arises in both cases)Before Chip's work, when liveslots needs to serialize the arguments of
foo(pA)
, it would get into an infinite loop, with the following steps:args = [pA]
: allocate a new vpid for pA (call itpA.1
), usepA.then()
to register for its resolution, dosyscall.send(foo, ... pA.1)
.then
fires, and liveslots learns of the resolution data (i.e.[pB]
). Liveslots serializes that, allocating a new vpid for pB (call itpB.1
), and registeringpB.then()
to learn about its resolution. Liveslots then doessyscall.resolve(pA.1, data=...pB.1)
.pA.1
, and the kernel deletes the c-list entry forpA.1
.then
fires, and liveslots learns of the pB resolution data (i.e.[pA]
). Liveslots serializes that, but since we retiredpA.1
and no longer recognize pA, liveslots must allocate a new identifier: call itpA.2
. Liveslots attaches a newthen
, and doessyscall.resolve(pB.1, data=...pA.2)
pB.1
[pB]
, which is unrecognized, so we allocatepB.2
, etcThe core problem was that we walk a cyclic graph one edge per turn, and forget each edge before the next turn can execute, so we don't have enough memory to handle the cycle.
Our fix is to defer retiring the identifiers until we've finished walking the graph. In our example (where the vat is initiating the resolution), we must add a WeakMap that remembers the data of resolved promises, so that we can walk the resolved subset of the graph in a single turn (note: we don't need to know that the promise is resolved per se, just whether liveslots has observed it to be resolved or not, so a WeakMap is sufficient). In addition, when liveslots reacts to a resolved exported Promise and walks the graph of its resolution, we build up a list of all the new promise identifiers that we've introduced (some of which might be resolved, while the rest are unresolved). We change
syscall.resolve
to accept a batch of resolutions, and change our rule about retiring c-list entries to run at the end of the batch. We may create some very short-lived promise identifiers: they'll be introduced, resolved, and retired, all in a single syscall.The loop starts by serializing the one promise results whose
.then
has fired, creating a capdata and a list of newly-introduced promises. Once that first promise is handled, we scan the list to find any which are already known to be resolved. We then serialize the results of that one, possibly adding to our list. We keep going until there are no more already-resolved promises in our list. Then we build a batchsyscall.resolve
that contains all of them. After executing this syscall, we retire the liveslots table entries for every identifier the syscall resolved.This will convert our mutually-recursive example (assuming liveslots has not yet seen either promise) to:
args = [pA]
: allocate a new vpid for pA (call itpA.1
), usepA.then()
to register for its resolution, and dosyscall.send(foo, ... pA.1)
.then
fires, and liveslots learns of the resolution data (i.e.[pB]
). We first recordpA -> [pB]
in the WeakMap. Then liveslots serializes[pB]
, allocating a new vpid for pB (call itpB.1
), and registeringpB.then()
to learn about its resolution. The "list of introduced promises" includes pB, but pB is not yet known to be resolved, so thesyscall.resolve([ [pA.1, [pB.1]] ])
call only resolves a single promise. We retirepA.1
as usual..then
fires, and liveslots learns of the pB resolution data (i.e.[pA]
) and stashespB -> [pA]
in the WeakMap. Liveslots serializes[pA]
, but since we retiredpA.1
and no longer recognize pA, liveslots must allocate a new identifier: call itpA.2
. The first serialization's "list of introduced promises" contains pA, which now we do recognize as being resolved. So liveslots re-serializes pA's resolution data ([pB]
). We haven't retiredpB.1
yet (we're still serializing), so the serialized data gets to referencepB.1
, closing the cycle. Liveslots does a batch-size=2syscall.resolve([ [pB.1, [pA.2]], [pA.2, [pB.1]] ])
. When this is done, liveslots retires bothpB.1
andpA.2
, as does the kernel.(if liveslots happened to see pA earlier, it wouldn't need to allocate
pA.2
)A similar process will happen when it is the kernel that resolves a batch of promises in a single delivery. The kernel has the full
capdata.slots
list available immediately, so the task of building the bundle is much easier. When liveslots receives thedispatch.notify(promisesAndResolutions)
, it must unserialize each one in turn, which may create new Promise objects, some of which may be resolved by other elements of the bundle (and the rest remain unresolved until some future delivery). Liveslots needs tosyscall.subscribe()
to only the unresolved remnants. So we need a way for unserialization to accumulate a list of promise identifiers that were encountered during the revival process, so we can filter it into the resolved and the unresolved.Context object via serialize/unserialize wrapper
So we need a way for liveslot's use of
serialize
andunserialize
to accumulate a list of things (promises and/or promise identifiers) encountered by thevalToSlot
andslotToVal
callbacks. Our initial implementation will introduce an accumulator array, closed over (and appended to) by bothvalToSlot
andslotToVal
. When liveslots is about to callserialize
, it will assert that this accumulator is empty. Afterserialize
is done, it will retrieve (and clear) the array. It will do the same forunserialize
.The risk of this "dynamic scope" approach is that any accidental recursion (
valToSlot
callingserialize
again: unlikely but conceivable) would corrupt this single/shared array. Plus, it introduces some unnecessary coupling between parts of liveslots.Context object as part of marshal
If
serialize()
accepted an extracontext
argument, liveslots could pass a new mutable array in. ThenvalToSlot(val, context)
would append the introduced promise withcontext.push(p)
instead of closing over something from a higher scope.Description of the Design
serialize(val)
becomesserialize(val, context)
valToSlot(val)
becomesvalToSlot(val, context)
unserialize(data)
becomesunserialize(data, context)
slotToVal(slot)
becomesslotToVal(slot, context)
There are probably other ways to approach this. We could decide that a completely generic context object is unnecessary if we really only need to accumulate an array. In that case, we could let
valToSlot()
return an additional list of things to append to the list, and have marshal manage the list:{ data, list } = serialize(val)
{ slot, list } = valToSlot(val)
{ val, list } = unserialize(data)
{ val, list } = slotToVal(slot)
We might be uncomfortable with passing a mutable array into a function (harden all the things!), in which case we'd need to build an append-only facet object in as the context.
Security Considerations
I don't think this introduces any. The caller of
marshal
already gets to provideslotToVal
andvalToSlot
. And the fact that we can implement similar functionality (less safely) via closed-over accumulator variables suggests that this change would not introduce any additional authority.Test Plan
Regular unit tests.
The text was updated successfully, but these errors were encountered: