Skip to content

Commit 24461a2

Browse files
committed
Keep what a call answered, so a path through it leads somewhere
A reference which came out of the result of a method call named a place inside that result, and there was nowhere to resolve it: the path was walked again from the event, landed on the method, and was told the property did not exist. The server was left holding a reference to something the page could never reach. An object now keeps what each path called on it answered, for as long as the event itself is kept and up to a small number of the most recent calls. Walking a path which leads through a method steps through that answer rather than the function, and the method is not run again to get it - it may have side effects, and by then it may not even be there.
1 parent 3cc449f commit 24461a2

3 files changed

Lines changed: 190 additions & 11 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ Don't forget to remove deprecated code on each major release!
4545
- Properties which are in flight at the same moment go out as a single message, and `await event.read("target.value", "key")` reads several at once.
4646
- A read belongs to the moment it is awaited: it reports the current state of the browser object, not the state it held when the event was dispatched.
4747
- Whatever a read found is remembered against the event, so reading one property twice costs one round trip - and a handler which wants what the browser holds by now asks again with `await event.read("target.value", refresh=True)`.
48+
- Whatever comes back out of a method call can be walked too: the page keeps what the call answered for as long as it keeps the event, so a reference into a result resolves against that answer rather than against the method.
4849
- Unanswered requests are bounded by `reactpy.config.REACTPY_EVENT_PROXY_TIMEOUT`, `reactpy.config.REACTPY_EVENT_PROXY_MAX_TIMEOUTS`, and `reactpy.config.REACTPY_EVENT_PROXY_MAX_DEPTH`.
4950

5051
### Changed

‎src/js/packages/@reactpy/client/src/event-proxy.ts‎

Lines changed: 90 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -406,22 +406,94 @@ export function readProperty(node: any, name: string): any {
406406
}
407407
}
408408

409-
/** Follow a full property path, refusing anything blocked along the way */
410-
export function readPath(root: any, path: string[]): any {
409+
/**
410+
* Follow a full property path, refusing anything blocked along the way.
411+
*
412+
* ``calls`` is what this object has already answered to for a method on it, which is
413+
* what lets a path leading through that method be resolved against the answer rather
414+
* than against the function itself.
415+
*/
416+
export function readPath(
417+
root: any,
418+
path: string[],
419+
calls?: ReadonlyMap<string, unknown>,
420+
): any {
411421
let node = root;
412-
for (const segment of path) node = readProperty(node, segment);
422+
for (let index = 0; index < path.length; index++)
423+
node = stepDown(node, path, index, calls);
413424
return node;
414425
}
415426

416-
function parentAndKey(root: any, path: string[]): { parent: any; key: string } {
427+
function parentAndKey(
428+
root: any,
429+
path: string[],
430+
calls?: ReadonlyMap<string, unknown>,
431+
): { parent: any; key: string } {
417432
if (path.length === 0)
418433
throw new ProxyAccessError("an empty path cannot be resolved");
419434
let node = root;
420-
for (const segment of path.slice(0, -1)) node = readProperty(node, segment);
435+
for (let index = 0; index < path.length - 1; index++)
436+
node = stepDown(node, path, index, calls);
421437
return { parent: node, key: path[path.length - 1] };
422438
}
423439

424-
type LiveObject = { value: any; added: number };
440+
type LiveObject = {
441+
value: any;
442+
added: number;
443+
/**
444+
* What each path called on this object answered with, most recent last.
445+
*
446+
* A reference which came back from inside the result of a call names a place inside
447+
* that result, and the result is the only thing it can be resolved against. Keeping
448+
* it is what makes such a path walkable; replaying the call is not an option, since a
449+
* method may have side effects and may not even be there any more.
450+
*/
451+
calls: Map<string, unknown>;
452+
};
453+
454+
/** How many call results one object keeps around to walk paths through */
455+
export const MAX_CALL_RESULTS_PER_OBJECT = 16;
456+
457+
/** What a recorded call is filed under - its own path, and nothing else's */
458+
function callKey(path: string[]): string {
459+
return JSON.stringify(path);
460+
}
461+
462+
/** Keep what a call answered, so that a path which leads through it still resolves */
463+
function rememberCall(entry: LiveObject, path: string[], value: unknown): void {
464+
if (
465+
value === null ||
466+
(typeof value !== "object" && typeof value !== "function")
467+
)
468+
return; // a primitive has nothing inside it to walk into
469+
entry.calls.set(callKey(path), value);
470+
while (entry.calls.size > MAX_CALL_RESULTS_PER_OBJECT) {
471+
const oldest = entry.calls.keys().next();
472+
if (oldest.done === true) break;
473+
entry.calls.delete(oldest.value);
474+
}
475+
}
476+
477+
/**
478+
* Step one segment deeper into a live value.
479+
*
480+
* A path which leads through a method is stepped through what that method answered
481+
* instead, whenever the page has been asked to call it before: the reference the server
482+
* is holding came out of that answer, and asking the method again would be both a second
483+
* side effect and a different answer.
484+
*/
485+
function stepDown(
486+
node: any,
487+
path: string[],
488+
index: number,
489+
calls?: ReadonlyMap<string, unknown>,
490+
): any {
491+
if (typeof node === "function" && calls) {
492+
const answer = calls.get(callKey(path.slice(0, index)));
493+
if (answer !== undefined) node = answer;
494+
}
495+
return readProperty(node, path[index]);
496+
}
425497

426498
/**
427499
* Keeps the objects which recently triggered an event around so the server can walk
@@ -431,6 +503,9 @@ type LiveObject = { value: any; added: number };
431503
* about them is sent when the event is dispatched, so forgetting one is not a slowdown
432504
* but a dead end - every later request for it is answered with "not-found". Entries are
433505
* still bounded in count and in age, since a page cannot hold on to events forever.
506+
* Whatever a method on one of them answered is kept alongside it, for as long as the
507+
* entry lasts and up to a small number of calls, because a reference which came out of
508+
* such an answer names a place inside it and has nowhere else to be resolved against.
434509
* Every value which leaves this registry goes through ``serializeValue``, so functions
435510
* and anything which could be used to escape the page never do.
436511
*/
@@ -451,7 +526,7 @@ export class EventProxyRegistry {
451526
/** Remember ``value`` and return the id the server will address it by */
452527
register(value: any): string {
453528
const eventId = `${this.clock().toString(36)}-${(this.nextId++).toString(36)}`;
454-
this.live.set(eventId, { value, added: this.clock() });
529+
this.live.set(eventId, { value, added: this.clock(), calls: new Map() });
455530
this.prune();
456531
return eventId;
457532
}
@@ -588,7 +663,11 @@ export class EventProxyRegistry {
588663
path,
589664
message.args || [],
590665
maxDepth,
666+
entry.calls,
591667
);
668+
// Whatever the server was handed may have come out of this value, so it has to
669+
// stay around or the path to it leads nowhere.
670+
rememberCall(entry, path, value);
592671
return reply(true, {
593672
value: serializeValue(value, eventId, path, options),
594673
});
@@ -599,8 +678,7 @@ export class EventProxyRegistry {
599678
value: serializeValue(entry.value, eventId, path, options),
600679
});
601680

602-
const { parent, key } = parentAndKey(entry.value, path);
603-
const value = readProperty(parent, key);
681+
const value = readPath(entry.value, path, entry.calls);
604682
if (typeof value === "function")
605683
return reply(true, { value: reference(eventId, path, true) });
606684
return reply(true, {
@@ -620,10 +698,11 @@ export class EventProxyRegistry {
620698
path: string[],
621699
args: any[],
622700
maxDepth: number,
701+
calls?: ReadonlyMap<string, unknown>,
623702
): any {
624703
if (path.length === 0)
625704
throw new ProxyAccessError("the event object itself cannot be called");
626-
const { parent, key } = parentAndKey(root, path);
705+
const { parent, key } = parentAndKey(root, path, calls);
627706
const method = readProperty(parent, key);
628707
if (typeof method !== "function")
629708
throw new ProxyAccessError(`${JSON.stringify(key)} is not a function`);
@@ -657,7 +736,7 @@ export class EventProxyRegistry {
657736
`nothing is registered for event ${JSON.stringify(ref.eventId)}`,
658737
);
659738
entry.added = this.clock();
660-
return readPath(entry.value, ref.path);
739+
return readPath(entry.value, ref.path, entry.calls);
661740
}
662741
if (Array.isArray(argument))
663742
return argument.map((item) =>

‎src/js/packages/@reactpy/client/tests/event-proxy.test.ts‎

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
type EventProxyResponse,
1616
LIVE_OBJECT_TTL_MS,
1717
MAX_ALLOWED_DEPTH,
18+
MAX_CALL_RESULTS_PER_OBJECT,
1819
MAX_LIVE_OBJECTS,
1920
MAX_REQUESTS_PER_MESSAGE,
2021
ProxyAccessError,
@@ -540,6 +541,104 @@ test("calling refuses the event itself, non-functions, and blocked members", ()
540541
).toBe("access");
541542
});
542543

544+
test("a path which leads through a call result is still walkable", () => {
545+
// What comes back out of a call is named by a path leading through it, and the only
546+
// thing such a path can be resolved against is the answer the page already gave. It
547+
// used to lead to a function with no such property, and answer "not-found" - leaving
548+
// the server holding a reference to something it could never reach.
549+
const { registry } = make_registry();
550+
const note: Record<string, unknown> = { text: "written by the browser" };
551+
note["about"] = note; // a cycle, which is handed over as a reference
552+
const eventId = registry.register({
553+
reveal() {
554+
return note;
555+
},
556+
});
557+
558+
const called = registry.resolve(
559+
request({ requestId: "call", eventId, path: ["reveal"], call: true }),
560+
);
561+
expect(called.ok).toBe(true);
562+
expect(readReference(called.value["about"])).toEqual({
563+
eventId,
564+
path: ["reveal", "about"],
565+
callable: false,
566+
});
567+
568+
const walked = registry.resolve(
569+
request({ requestId: "walk", eventId, path: ["reveal", "about", "text"] }),
570+
);
571+
expect(walked.ok).toBe(true);
572+
expect(walked.value).toBe("written by the browser");
573+
});
574+
575+
test("walking through a call result does not run the method again", () => {
576+
// The answer is kept rather than the call being made again: a method may have side
577+
// effects, and by now it may not even be there.
578+
const { registry } = make_registry();
579+
let runs = 0;
580+
const eventId = registry.register({
581+
make() {
582+
runs++;
583+
return { deep: { value: 42 } };
584+
},
585+
});
586+
587+
const called = registry.resolve(
588+
request({
589+
requestId: "call",
590+
eventId,
591+
path: ["make"],
592+
call: true,
593+
maxDepth: 1,
594+
}),
595+
);
596+
expect(runs).toBe(1);
597+
expect(readReference(called.value["deep"])?.path).toEqual(["make", "deep"]);
598+
599+
const walked = registry.resolve(
600+
request({ requestId: "walk", eventId, path: ["make", "deep", "value"] }),
601+
);
602+
expect(walked.ok).toBe(true);
603+
expect(walked.value).toBe(42);
604+
expect(runs).toBe(1);
605+
});
606+
607+
test("only the most recent call results are kept to walk through", () => {
608+
// Keeping results is what keeps a page's memory bounded, so the oldest make room for
609+
// the newest rather than being held for as long as the event itself is.
610+
const { registry } = make_registry();
611+
const methods: Record<string, () => unknown> = {};
612+
for (let index = 0; index <= MAX_CALL_RESULTS_PER_OBJECT; index++)
613+
methods[`item${index}`] = () => ({ at: index });
614+
const eventId = registry.register(methods);
615+
616+
for (let index = 0; index <= MAX_CALL_RESULTS_PER_OBJECT; index++)
617+
registry.resolve(
618+
request({
619+
requestId: `call-${index}`,
620+
eventId,
621+
path: [`item${index}`],
622+
call: true,
623+
}),
624+
);
625+
626+
const kept = registry.resolve(
627+
request({
628+
eventId,
629+
path: [`item${MAX_CALL_RESULTS_PER_OBJECT}`, "at"],
630+
}),
631+
);
632+
expect(kept.ok).toBe(true);
633+
expect(kept.value).toBe(MAX_CALL_RESULTS_PER_OBJECT);
634+
635+
const forgotten = registry.resolve(
636+
request({ eventId, path: ["item0", "at"] }),
637+
);
638+
expect(forgotten.ok).toBe(false);
639+
expect(forgotten.code).toBe("not-found");
640+
});
641+
543642
test("arguments which are references are resolved on the page", () => {
544643
const { registry } = make_registry();
545644
const received: unknown[] = [];

0 commit comments

Comments
 (0)