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

Operation steps perform too many observable Gets #2289

Closed
gibson042 opened this issue Jun 12, 2022 · 14 comments
Closed

Operation steps perform too many observable Gets #2289

gibson042 opened this issue Jun 12, 2022 · 14 comments
Labels
behavior Relating to behavior defined in the proposal calendar Part of the effort for Temporal Calendar API normative Would be a normative change to the proposal
Milestone

Comments

@gibson042
Copy link
Collaborator

gibson042 commented Jun 12, 2022

Too many specifics of the internal spec algorithms are exposed in redundant observable interactions with input objects as opposed to up-front method/property extraction and subsequent invocation as necessary.

For example, in Temporal.Duration.compare, steps 7a and 7b are sequential calls to UnbalanceDurationRelative with largestUnit "day" and the same relativeTo argument extracted from compare options, each of which independently calls ToTemporalDate(relativeTo) (which observably Gets "calendar") and PrepareTemporalFields (which observably Gets the other Temporal fields) and also one more instances of MoveRelativeDate(calendar, relativeTo, one{Year,Month,Week}) (each of which itself observably gets the calendar's "dateAdd" via CalendarDateAdd). Temporal.Duration.compare also exhibits similar behavior with a pair of calls to CalculateOffsetShift(relativeTo, …), each of which observably Get the time zone's "getOffsetNanosecondsFor" (twice!) via GetOffsetNanosecondsFor.

Temporal.Duration.prototype.round and Temporal.Duration.prototype.total similarly send relativeTo to a variety of operations that redundantly extract information from it and/or its calendar and/or time zone, and I suspect the problem exists elsewhere as well (such as from AddDuration with plain object relativeTo observably calling AddZonedDateTime twice, cf. #2290 (comment) ).

Every entry point should instead extract the methods and properties once and then pass them down to deeper levels, resulting in more reasonable and opaque externally-observable behavior rather than something like this:

Temporal.Duration.compare(Temporal.Duration.from("PT0S"), Temporal.Duration.from("PT1S"), {
  relativeTo: new Proxy({}, {
    get(_, p) {
      if (["calendar", "monthCode", "offset"].includes(p)) return undefined;
      if (p === "timeZone") {
        return new Proxy(
          {
            getPossibleInstantsFor() { return [new Temporal.Instant(0n)]; },
            getOffsetNanosecondsFor() { return 0; },
          },
          {
            get(target, p) {
              console.log(`Get relativeTo.timeZone.${String(p)}`);
              return target[p];
            }
          }
        );
      }
      return 1;
    }
  })
});

/* polyfill logs 6 Gets of `getOffsetNanosecondsFor`, even without nonzero years/months/days:
Get relativeTo.timeZone
Get relativeTo.timeZone.getPossibleInstantsFor
Get relativeTo.timeZone.getOffsetNanosecondsFor
Get relativeTo.timeZone.getOffsetNanosecondsFor
Get relativeTo.timeZone.Symbol(Symbol.toPrimitive)
Get relativeTo.timeZone.toString
Get relativeTo.timeZone.Symbol(Symbol.toStringTag)
Get relativeTo.timeZone.getOffsetNanosecondsFor
Get relativeTo.timeZone.getOffsetNanosecondsFor
Get relativeTo.timeZone.getOffsetNanosecondsFor
Get relativeTo.timeZone.getOffsetNanosecondsFor
*/
@gibson042 gibson042 added behavior Relating to behavior defined in the proposal calendar Part of the effort for Temporal Calendar API labels Jun 12, 2022
@anba
Copy link
Contributor

anba commented Jun 13, 2022

FWIW, the correct output should be:

Get relativeTo.timeZone.getPossibleInstantsFor
Get relativeTo.timeZone.getOffsetNanosecondsFor
Get relativeTo.timeZone.getOffsetNanosecondsFor
Get relativeTo.timeZone.getOffsetNanosecondsFor
Get relativeTo.timeZone.getOffsetNanosecondsFor

The other Get calls are caused by the TemporalZonedDateTimeToString call for the debug representation:

value: `${result[Symbol.toStringTag]} <${ES.TemporalZonedDateTimeToString(result, 'auto')}>`,

@ptomato ptomato added the normative Would be a normative change to the proposal label Jun 21, 2022
@ptomato
Copy link
Collaborator

ptomato commented Jul 5, 2022

#2267 addresses the repeated Gets of dateAdd in MoveRelativeDate, but doesn't solve the whole issue.

@gibson042
Copy link
Collaborator Author

gibson042 commented Dec 8, 2022

Another example: DifferenceTemporalPlainDate and DifferenceTemporalPlainDateTime and DifferenceTemporalZonedDateTime clone options after sending it to GetDifferenceSettings, resulting in multiple reads of some properties. All of the following produce identical log output:

Temporal.Now.plainDateISO().since(Temporal.Now.plainDateISO(), {
  get ignored() { console.log("get ignored"); },
  get smallestUnit() { console.log("get smallestUnit"); },
  get largestUnit() { console.log("get largestUnit"); },
});

Temporal.Now.plainDateTimeISO().since(Temporal.Now.plainDateTimeISO(), {
  get ignored() { console.log("get ignored"); },
  get smallestUnit() { console.log("get smallestUnit"); },
  get largestUnit() { console.log("get largestUnit"); },
});

Temporal.Now.zonedDateTimeISO().since(Temporal.Now.zonedDateTimeISO(), {
  get ignored() { console.log("get ignored"); },
  get smallestUnit() { console.log("get smallestUnit"); },
  get largestUnit() { console.log("get largestUnit"); return "day"; },
});

Temporal.Now.plainDateISO().toPlainYearMonth().since(Temporal.Now.plainDateISO().toPlainYearMonth(), {
  get ignored() { console.log("get ignored"); },
  get smallestUnit() { console.log("get smallestUnit"); },
  get largestUnit() { console.log("get largestUnit"); },
});
get largestUnit
get smallestUnit
get ignored
get smallestUnit
get largestUnit

@gibson042
Copy link
Collaborator Author

gibson042 commented Jan 31, 2023

Another example: widespread use of iterators in Temporal means that e.g. custom Calendar fields and TimeZone getPossibleInstantsFor returning arrays (as even the built-in prototype methods are specified to do) can be preempted by code that overrides Array.prototype[Symbol.iterator]. For example:

const originalGetIterator = Array.prototype[Symbol.iterator];
Array.prototype[Symbol.iterator] = function (...args) {
  const iterator = Reflect.apply(originalGetIterator, this, args);
  const iteratorProxy = new Proxy(iterator, {
    get(target, key) {
      const propValue = target[key];
      // Wrap the "next" method to capriciously subtract 12 years from any Instant result.
      if (key !== "next" || typeof propValue !== "function") return propValue;
      return new Proxy(propValue, {
        apply(target, thisArg, args) {
          const result = Reflect.apply(target, thisArg === iteratorProxy ? iterator : thisArg, args);
          const value = result?.value;
          if (value instanceof Temporal.Instant) {
            result.value = value.subtract({ hours: 12 * 365.25 * 24 });
          }
          return result;
        },
      });
    },
  });
  return iteratorProxy;
};
const zdt = Temporal.ZonedDateTime.from("2023-01-31T10:00[America/New_York]");
zdt.with({ hour: 0 });
// => 1999-01-31T00:00:00-05:00[America/New_York]

Given that expected results are small, I think results should be consumed as arraylikes rather than iterables.

@ptomato
Copy link
Collaborator

ptomato commented Feb 7, 2023

Given that expected results are small, I think results should be consumed as arraylikes rather than iterables.

Iterables were requested during the editors' review for Stage 3 (#1427). Kevin clarified later in the long thread in #1610 that there was no problem with reverting back to arraylikes if there was a good reason, but accepting iterables should be the default for new facilities in the language. I'd guess that "userland could patch Array[Symbol.iterator]" is not a good enough justification though, otherwise we just wouldn't accept iterables anywhere.

@gibson042
Copy link
Collaborator Author

Are we sure that's not good enough reason? It even allows userland code to override built-ins at a distance, which in addition to hindering comprehensibility, seems like a bug factory—potentially with security implications. I'm not aware of any existing interface between built-ins in which output from one is consumed as an iterator without a short-circuit that inherently prevents observation and/or interference by user code such as the implicit constructor in ClassDefinitionEvaluation (which is also true of Reflect.{apply,construct} argument consumption, and even of the handoff from an "ownKeys" proxy trap to [[OwnPropertyKeys]]), but perhaps @bakkot has a specific pattern in mind?

@bakkot
Copy link
Contributor

bakkot commented Feb 7, 2023

My comment about accepting iterables as inputs was meant for user-exposed APIs, not for stuff which is internal to an algorithm. Why does the snippet above end up triggering the iteration protocol at all? If you're using a built-in calendar and timezone, don't you know their fields already?

I don't think "you can change the behavior of this algorithm by patching Array.prototype[Symbol.iterator]" is obviously any more of a problem than "you can change the behavior of this unrelated API by by patching the Temporal.Calendar.prototype.fields getter" (or whatever it is that actually triggers this behavior). I agree neither seems great, but it is not totally clear to me why we'd care about the first if we are OK with the second.

@gibson042
Copy link
Collaborator Author

gibson042 commented Feb 7, 2023

I'm not OK with either, and I think both are in scope for this issue. But there's a logical difference between patching a built-in API vs. interfering with the mechanism by which built-in APIs communicate.

@bakkot
Copy link
Contributor

bakkot commented Feb 7, 2023

If the built-in API is part of the mechanism by which other APIs communicate, I don't see what difference you're pointing at. const zdt = Temporal.ZonedDateTime.from("2023-01-31T10:00[America/New_York]"); zdt.with({ hour: 0 }); doesn't contain any references to arrays or to calendars, so it seems like both ought to constitute "mechanisms by which built-in APIs communicate".

@ptomato
Copy link
Collaborator

ptomato commented Feb 7, 2023

With the change that we approved at last week's plenary, Temporal.ZonedDateTime.from("2023-01-31T10:00[America/New_York]").with({ hour: 0 }) would indeed no longer be affected by any monkeypatching at all, because no lookups would occur any longer on any builtin calendar and time zone methods.

I'm pretty sure it's still possible to write a code sample that would have the problem Richard was trying to illustrate, but it'd need to use a custom calendar or time zone.

@bakkot
Copy link
Contributor

bakkot commented Feb 8, 2023

Ah, nice. (I assume that's #2482.)

If the path which is affected by patching Array.prototype[Symbol.iterator] is indeed only triggered with custom calendars or timezones, I'm a lot less worried about it.

@justingrant
Copy link
Collaborator

I'm pretty sure it's still possible to write a code sample that would have the problem Richard was trying to illustrate, but it'd need to use a custom calendar or time zone.

It would also be possible to get this behavior if a built-in timezone or calendar is constructed as an object (e.g. Temporal.TimeZone.from('America/Denver')) as opposed to being supplied as a string. So it's possible for built-ins to end up there, but it can be avoided by always using string IDs. But, honestly, once a built-in object is an object, then it can be monkeyed around with in many different ways. Iteration is just one of many.

@ptomato
Copy link
Collaborator

ptomato commented Mar 7, 2023

I was half right in my previous comment. Temporal.ZonedDateTime.from("2023-01-31T10:00[America/New_York]").with({ hour: 0 }) will no longer look up Temporal.Calendar.prototype.fields and call it, but it'll still call the intrinsic %Temporal.Calendar.prototype.fields%, which will iterate an Array observably. I have figured out a way to avoid this observable iteration for built-in calendars, which I'll include in the pull request for this issue.

ptomato added a commit that referenced this issue Mar 11, 2023
…ds()

NOTE: This commit doesn't actually do anything in this current branch,
because CalendarFields is never called with a String _calendar_. This just
illustrates the change, which will only take effect when #2482 is merged.

When calling CalendarFields with a built-in calendar, previously we would
create a JS Array from the passed List, and pass it to %Temporal.Calendar.
prototype.fields%, which would iterate it observably. So, instead we call
CalendarDateFields when the calendar is a built-in calendar, which doesn't
do anything observable at all.

See: #2289
ptomato added a commit that referenced this issue Apr 22, 2023
…ds()

NOTE: This commit doesn't actually do anything in this current branch,
because CalendarFields is never called with a String _calendar_. This just
illustrates the change, which will only take effect when #2482 is merged.

When calling CalendarFields with a built-in calendar, previously we would
create a JS Array from the passed List, and pass it to %Temporal.Calendar.
prototype.fields%, which would iterate it observably. So, instead we call
CalendarDateFields when the calendar is a built-in calendar, which doesn't
do anything observable at all.

See: #2289
ptomato added a commit that referenced this issue Apr 26, 2023
…ds()

NOTE: This commit doesn't actually do anything in this current branch,
because CalendarFields is never called with a String _calendar_. This just
illustrates the change, which will only take effect when #2482 is merged.

When calling CalendarFields with a built-in calendar, previously we would
create a JS Array from the passed List, and pass it to %Temporal.Calendar.
prototype.fields%, which would iterate it observably. So, instead we call
CalendarDateFields when the calendar is a built-in calendar, which doesn't
do anything observable at all.

See: #2289
ptomato added a commit that referenced this issue Apr 27, 2023
…ds()

When calling CalendarFields with a built-in calendar, previously we would
create a JS Array from the passed List, and pass it to %Temporal.Calendar.
prototype.fields%, which would iterate it observably. So, instead we call
CalendarDateFields when the calendar is a built-in calendar, which doesn't
do anything observable at all.

See: #2289
ptomato added a commit that referenced this issue May 3, 2023
…ds()

When calling CalendarFields with a built-in calendar, previously we would
create a JS Array from the passed List, and pass it to %Temporal.Calendar.
prototype.fields%, which would iterate it observably. So, instead we call
CalendarDateFields when the calendar is a built-in calendar, which doesn't
do anything observable at all.

See: #2289
ptomato added a commit that referenced this issue May 11, 2023
…ds()

When calling CalendarFields with a built-in calendar, previously we would
create a JS Array from the passed List, and pass it to %Temporal.Calendar.
prototype.fields%, which would iterate it observably. So, instead we call
CalendarDateFields when the calendar is a built-in calendar, which doesn't
do anything observable at all.

See: #2289
ptomato added a commit that referenced this issue May 13, 2023
…ds()

When calling CalendarFields with a built-in calendar, previously we would
create a JS Array from the passed List, and pass it to %Temporal.Calendar.
prototype.fields%, which would iterate it observably. So, instead we call
CalendarDateFields when the calendar is a built-in calendar, which doesn't
do anything observable at all.

See: #2289
ptomato added a commit that referenced this issue May 13, 2023
…ds()

When calling CalendarFields with a built-in calendar, previously we would
create a JS Array from the passed List, and pass it to %Temporal.Calendar.
prototype.fields%, which would iterate it observably. So, instead we call
CalendarDateFields when the calendar is a built-in calendar, which doesn't
do anything observable at all.

See: #2289
ptomato added a commit that referenced this issue May 19, 2023
…ds()

When calling CalendarFields with a built-in calendar, previously we would
create a JS Array from the passed List, and pass it to %Temporal.Calendar.
prototype.fields%, which would iterate it observably. So, instead we call
CalendarDateFields when the calendar is a built-in calendar, which doesn't
do anything observable at all.

See: #2289
ptomato added a commit that referenced this issue May 31, 2023
…ds()

When calling CalendarFields with a built-in calendar, previously we would
create a JS Array from the passed List, and pass it to %Temporal.Calendar.
prototype.fields%, which would iterate it observably. So, instead we call
CalendarDateFields when the calendar is a built-in calendar, which doesn't
do anything observable at all.

See: #2289
ptomato added a commit that referenced this issue Jun 12, 2023
…ds()

When calling CalendarFields with a built-in calendar, previously we would
create a JS Array from the passed List, and pass it to %Temporal.Calendar.
prototype.fields%, which would iterate it observably. So, instead we call
CalendarDateFields when the calendar is a built-in calendar, which doesn't
do anything observable at all.

See: #2289
ptomato added a commit that referenced this issue Jun 20, 2023
…ds()

When calling CalendarFields with a built-in calendar, previously we would
create a JS Array from the passed List, and pass it to %Temporal.Calendar.
prototype.fields%, which would iterate it observably. So, instead we call
CalendarDateFields when the calendar is a built-in calendar, which doesn't
do anything observable at all.

See: #2289
ptomato added a commit that referenced this issue Aug 18, 2023
…ds()

When calling CalendarFields with a built-in calendar, previously we would
create a JS Array from the passed List, and pass it to %Temporal.Calendar.
prototype.fields%, which would iterate it observably. So, instead we call
CalendarDateFields when the calendar is a built-in calendar, which doesn't
do anything observable at all.

See: #2289
ptomato added a commit that referenced this issue Aug 18, 2023
…ds()

When calling CalendarFields with a built-in calendar, previously we would
create a JS Array from the passed List, and pass it to %Temporal.Calendar.
prototype.fields%, which would iterate it observably. So, instead we call
CalendarDateFields when the calendar is a built-in calendar, which doesn't
do anything observable at all.

See: #2289
ptomato added a commit that referenced this issue Aug 22, 2023
…ds()

When calling CalendarFields with a built-in calendar, previously we would
create a JS Array from the passed List, and pass it to %Temporal.Calendar.
prototype.fields%, which would iterate it observably. So, instead we call
CalendarDateFields when the calendar is a built-in calendar, which doesn't
do anything observable at all.

See: #2289
ptomato added a commit that referenced this issue Aug 23, 2023
…ds()

When calling CalendarFields with a built-in calendar, previously we would
create a JS Array from the passed List, and pass it to %Temporal.Calendar.
prototype.fields%, which would iterate it observably. So, instead we call
CalendarDateFields when the calendar is a built-in calendar, which doesn't
do anything observable at all.

See: #2289
ptomato added a commit that referenced this issue Aug 26, 2023
…ds()

When calling CalendarFields with a built-in calendar, previously we would
create a JS Array from the passed List, and pass it to %Temporal.Calendar.
prototype.fields%, which would iterate it observably. So, instead we call
CalendarDateFields when the calendar is a built-in calendar, which doesn't
do anything observable at all.

See: #2289
ptomato added a commit that referenced this issue Sep 11, 2023
…ds()

When calling CalendarFields with a built-in calendar, previously we would
create a JS Array from the passed List, and pass it to %Temporal.Calendar.
prototype.fields%, which would iterate it observably. So, instead we call
CalendarDateFields when the calendar is a built-in calendar, which doesn't
do anything observable at all.

See: #2289
ptomato added a commit that referenced this issue Sep 12, 2023
…ds()

When calling CalendarFields with a built-in calendar, previously we would
create a JS Array from the passed List, and pass it to %Temporal.Calendar.
prototype.fields%, which would iterate it observably. So, instead we call
CalendarDateFields when the calendar is a built-in calendar, which doesn't
do anything observable at all.

See: #2289
ptomato added a commit that referenced this issue Sep 12, 2023
…ds()

When calling CalendarFields with a built-in calendar, previously we would
create a JS Array from the passed List, and pass it to %Temporal.Calendar.
prototype.fields%, which would iterate it observably. So, instead we call
CalendarDateFields when the calendar is a built-in calendar, which doesn't
do anything observable at all.

See: #2289
ptomato added a commit that referenced this issue Sep 13, 2023
…ds()

When calling CalendarFields with a built-in calendar, previously we would
create a JS Array from the passed List, and pass it to %Temporal.Calendar.
prototype.fields%, which would iterate it observably. So, instead we call
CalendarDateFields when the calendar is a built-in calendar, which doesn't
do anything observable at all.

See: #2289
ptomato added a commit that referenced this issue Sep 13, 2023
…ds()

When calling CalendarFields with a built-in calendar, previously we would
create a JS Array from the passed List, and pass it to %Temporal.Calendar.
prototype.fields%, which would iterate it observably. So, instead we call
CalendarDateFields when the calendar is a built-in calendar, which doesn't
do anything observable at all.

See: #2289
@ptomato
Copy link
Collaborator

ptomato commented Nov 16, 2023

Completed by #2519.

@ptomato ptomato closed this as completed Nov 16, 2023
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
behavior Relating to behavior defined in the proposal calendar Part of the effort for Temporal Calendar API normative Would be a normative change to the proposal
Projects
None yet
Development

No branches or pull requests

5 participants