Skip to content
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

fix(vow): handle resolution loops in vows #9561

Merged
merged 4 commits into from
Jun 22, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 28 additions & 3 deletions packages/vow/src/watch.js
Original file line number Diff line number Diff line change
Expand Up @@ -65,8 +65,23 @@ const settle = (resolver, watcher, wcb, value, watcherArgs = []) => {
* @param {IsRetryableReason} isRetryableReason
* @param {ReturnType<typeof makeWatchNextStep>} watchNextStep
*/
const preparePromiseWatcher = (zone, isRetryableReason, watchNextStep) =>
zone.exoClass(
const preparePromiseWatcher = (zone, isRetryableReason, watchNextStep) => {
// We use an ephemeral WeakSet for the previously seen vows in a watch operation
// While watch is durable, it suffices to detect the cycle in a single incarnation
/** @type {WeakMap<PromiseWatcher, WeakSet<any>>} */
const watcherSeenPayloads = new WeakMap();

/** @param {PromiseWatcher} watcher */
const getSeenPayloads = watcher => {
let seenPayloads = watcherSeenPayloads.get(watcher);
if (!seenPayloads) {
seenPayloads = new WeakSet();
watcherSeenPayloads.set(watcher, seenPayloads);
}
return seenPayloads;
};

return zone.exoClass(
'PromiseWatcher',
PromiseWatcherI,
/**
Expand All @@ -91,12 +106,20 @@ const preparePromiseWatcher = (zone, isRetryableReason, watchNextStep) =>
/** @type {Required<PromiseWatcher>['onFulfilled']} */
onFulfilled(value) {
const { watcher, watcherArgs, resolver } = this.state;
if (getVowPayload(value)) {
const payload = getVowPayload(value);
if (payload) {
const seenPayloads = getSeenPayloads(this.self);
// TODO: rely on endowed helper to get storable cap from payload
if (seenPayloads.has(payload.vowV0)) {
return this.self.onRejected(Error('Vow resolution cycle detected'));
}
seenPayloads.add(payload.vowV0);
// We've been shortened, so reflect our state accordingly, and go again.
this.state.vow = value;
watchNextStep(value, this.self);
return;
}
watcherSeenPayloads.delete(this.self);
this.state.priorRetryValue = undefined;
this.state.watcher = undefined;
this.state.resolver = undefined;
Expand All @@ -115,13 +138,15 @@ const preparePromiseWatcher = (zone, isRetryableReason, watchNextStep) =>
return;
}
}
watcherSeenPayloads.delete(this.self);
this.state.priorRetryValue = undefined;
this.state.resolver = undefined;
this.state.watcher = undefined;
settle(resolver, watcher, 'onRejected', reason, watcherArgs);
},
},
);
};

/**
* @param {Zone} zone
Expand Down
10 changes: 9 additions & 1 deletion packages/vow/src/when.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,11 +28,19 @@ export const makeWhen = (
let result = await specimenP;
let payload = getVowPayload(result);
let priorRetryValue;
const seenPayloads = new WeakSet();
while (payload) {
result = await basicE(payload.vowV0)
// TODO: rely on endowed helpers for getting storable cap and performing
// shorten "next step"
const { vowV0 } = payload;
Copy link
Member

Choose a reason for hiding this comment

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

Leaving as a TODO for later, I don't want to impede the pragmatic goal of getting this PR merged.

TODO: encapsulate this extraction in vow-utils.js as a subfunction used by toPassableCap, but taking a payload instead of a possible Vow:

/**
 * comments here...
 * @param {VowPayload}
 * @returns {PassableCap}
 */
export const vowPayloadToPassableCap = payload => payload.vowV0;

Doing this would require more thought to decide when we actually should depend on vowV0 vs when we only want a passable cap that can serve as a store key.

if (seenPayloads.has(vowV0)) {
throw Error('Vow resolution cycle detected');
}
result = await basicE(vowV0)
.shorten()
.then(
res => {
seenPayloads.add(vowV0);
priorRetryValue = undefined;
return res;
},
Expand Down
53 changes: 53 additions & 0 deletions packages/vow/test/watch.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,59 @@ test('watcher args arity - shim', async t => {
}
});

test('vow self resolution', async t => {
const zone = makeHeapZone();
const { watch, when, makeVowKit } = prepareVowTools(zone);

// A direct self vow resolution
const { vow: vow1, resolver: resolver1 } = makeVowKit();
resolver1.resolve(vow1);

// A self vow resolution through promise
const { vow: vow2, resolver: resolver2 } = makeVowKit();
const vow2P = Promise.resolve(vow2);
resolver2.resolve(vow2P);

// A 2 vow loop
const { vow: vow3, resolver: resolver3 } = makeVowKit();
const { vow: vow4, resolver: resolver4 } = makeVowKit();
resolver3.resolve(vow4);
resolver4.resolve(vow3);

// A head vow pointing to a 2 vow loop (a lasso?)
const { vow: vow5, resolver: resolver5 } = makeVowKit();
resolver5.resolve(vow4);

const turnTimeout = async n => {
if (n > 0) {
return Promise.resolve(n - 1).then(turnTimeout);
}

return 'timeout';
};

/**
* @param {number} n
* @param {Promise<any>} promise
*/
const raceTurnTimeout = async (n, promise) =>
Promise.race([promise, turnTimeout(n)]);

const expectedError = {
message: 'Vow resolution cycle detected',
};

await t.throwsAsync(raceTurnTimeout(20, when(vow1)), expectedError);
await t.throwsAsync(raceTurnTimeout(20, when(vow2)), expectedError);
await t.throwsAsync(raceTurnTimeout(20, when(vow3)), expectedError);
await t.throwsAsync(raceTurnTimeout(20, when(vow5)), expectedError);

await t.throwsAsync(raceTurnTimeout(20, when(watch(vow1))), expectedError);
await t.throwsAsync(raceTurnTimeout(20, when(watch(vow2))), expectedError);
await t.throwsAsync(raceTurnTimeout(20, when(watch(vow3))), expectedError);
await t.throwsAsync(raceTurnTimeout(20, when(watch(vow5))), expectedError);
});

test('disconnection of non-vow informs watcher', async t => {
const zone = makeHeapZone();
const { watch, when } = prepareVowTools(zone, {
Expand Down
Loading