Skip to content

Instantly share code, notes, and snippets.

@sbomer
Last active May 25, 2021 00:35
Show Gist options
  • Save sbomer/3a08652eec6bd62949924db626de3983 to your computer and use it in GitHub Desktop.
Save sbomer/3a08652eec6bd62949924db626de3983 to your computer and use it in GitHub Desktop.

I took a closer look at how we handled this for resources and now I understand @vitek-karas' point that it's not so different from the StartupHook case. I personally find this mind-boggling and would like to solve it differently. In my mind (maybe this is naiive) we should be able to reason simply about dependencies between feature switches. Imagine features A and B. If A always depends on B, things are straightforward:

[RUC("A")]
void DoA() {
    if (!A.IsSupported) throw;
    AImpl();
    DoB(); // no warning
}

[RUC("A")]
void AImpl() {}

[RUC("B")]
void DoB() {
    if (!B.IsSupported) throw;
    // ...
}

This won't warn for the dependency A -> B, but it'll warn if anything else tries to use A or B.

Now imagine the dependency is optional:

[RUC("A")]
void DoA() {
    if (!A.IsSupported) throw;
    AImpl();
    if (B.IsSupported) DoB(); // no warning
}

Not much changes - the warning is still suppressed due to the RUC, whether B is enabled or disabled. Then let's say feature A doesn't have RUC:

[USM]
void DoA() {
    if (!A.IsSupported) throw;
    AImpl();
    if (B.IsSupported) DoB();
}

void AImpl() {} // trim-friendly

In this case, we have the problem that callers of DoA won't warn even if feature A is disabled, but that's a separate issue (we might want to add some tooling to give build-time warnings for unguarded calls to disabled features in general).

For the dependency from A -> B, is it ok to suppress the warning? Branch elimination means the linker won't warn if B.IsSupported == false, but the warning is still there when there are no feature settings, or if B.IsSupported == true.

I would argue yes - the author of A has gone through the trouble to guard calls to DoB, and DoA does useful work even without B. If the app author enables feature B, that will cause A to use the "unsafe" path for B. But we shouldn't blame A for this; we should blame the app author for turning on B which is unsupported when trimming.

What if there isn't a nice fallback behavior - what if it throws?

void DoA() {
    if (!A.IsSupported) throw;
    AImpl();
    if (B.IsSupported) DoB();
    else throw;
}

Then I'd argue A has a "hard" dependency on B, and the dependency should be expressed via RUC and the feature switch to turn the runtime failure into a build-time warning. It should not introduce a separate "category" of RUC or feature switch for this. Instead it should be written as:

[RUC("B")]
void DoA() {
    if (!B.IsSupported) throw;
    AImpl();
    DoB();
}

In none of the above cases would you get a warning from A's internals. I think we should follow the same patterns in the library feature switches.

Compare this with the current approach we have for custom resource types and binary formatter. There's a feature AllowCustomResourceTypes which when enabled lights up optional code paths for 1. reading custom resource types and 2. deserializing from the resources.

Here are the important bits extracted as pseudocode:

CreateResourceSet() {
    if (CanUseDefaultResourceClasses()) return;
    // ^In a lot of cases, we don't hit the custom resource type code
 
    if (AllowCustomResourceTypes) InternalGetResourceSetFromSerializeData() // warns if AllowCustomResourceTypes == true
    else throw;
}

[RUC("CustomResourceTypesSupport is incompatible with trimming")] // user-visible message
InternalGetResourceSetFromSerializedData() {
   new ResourceReader(..., permitDeserialization: true);
}

[USM("when AllowResourceTypes==true, we already warn above")]
ResourceReader.DeserializeObject() {
    if (!AllowCustomResourceTypes) throw;
    if (!permitDeserialization) throw;
    InitializeBinaryFormatter(); // warning suppressed
}

[RUC("CustomResourceTypesSupport is incompatible with trimming")] // we suppress warnings for calls to this method
ResourceReader.InitializeBinaryFormatter() {
    Type.GetType("BinaryFormatter").GetMethod("Deserialize").Invoke();
}

[RUC]
BinaryFormatter.Deserialize() {
    if (!BinaryFormatterEnabled) throw;
    // ...
}

Abstracting from this a little:

void DoA1() {
  if (HappyPath()) return; // happy path which is trim-friendly
  if (A.IsSupported) AImpl1(); // warns if A.IsSupported != false
  else throw;
}

[RUC("A")]
void AImpl1() {
  new FeatureA(useB: true);
}

[USM]
void FeatureA.DoA2() {
  if (!A.IsSupported) throw;
  if (!useB) throw;
  AImpl2(); // suppressed
}

[RUC("A")]
void AImpl2() {
  DoB(); // suppressed by RUC
}

[RUC("B")]
void DoB() {
  if (!B.IsSupported) throw;
  // ...
}

this is roughly equivalent to:

void DoA1() {
  if (HappyPath()) return; // happy path which is trim-friendly
  if (A.IsSupported) AImpl1(); // warns if A.IsSupported != false
  else throw;
}

[RUC("A")]
void AImpl1() {
  new FeatureA(useB: true);
}

[USM]
void FeatureA.DoA2() {
  if (A.IsSupported) DoB(); // suppressed
  else throw;
}

This pattern doesn't really extend well to other cases. The warning is suppressed only because we presumably have some guarantee that DoA2 can't be called independently of DoA1. (I am not even sure this is true for ResourceManager - couldn't you hit the deserialization code path even without custom resource types?) If there are other entry points to feature A that depend on B, then there would be multiple warnings if linking with A.IsSupported.

Could we instead change this to:

[USM]
void DoA1() {
  if (HappyPath()) return; // happy path which is trim-friendly
  if (B.IsSupported) AImpl1(); // suppressed
  else throw;
}

[RUC("B")]
void AImpl1() {
  new FeatureA(useB: true);
}

[USM]
void FeatureA.DoA2() {
  if (B.IsSupported) DoB(); // suppressed
  else throw;
}

or for ResourceManager:

[USM]
CreateResourceSet() {
    if (CanUseDefaultResourceClasses()) return;
    // ^In a lot of cases, we don't hit the custom resource type code
 
    if (BinaryFormatterEnabled) InternalGetResourceSetFromSerializeData() // suppressed
    else throw;
}

[RUC("BinaryFormatter")]
InternalGetResourceSetFromSerializedData() {
   new ResourceReader(..., permitDeserialization: true);
}

[RUC("BinaryFormatter")]
ResourceReader.DeserializeObject() {
    if (!BinaryFormatterEnabled) throw;
    InitializeBinaryFormatter(); // warning suppressed
}

[RUC("BinaryFormatter")]
ResourceReader.InitializeBinaryFormatter() {
    Type.GetType("BinaryFormatter").GetMethod("Deserialize").Invoke();
}

[RUC("BinaryFormatter")]
BinaryFormatter.Deserialize() {
    if (!BinaryFormatterEnabled) throw;
    // ...
}

This would produce no warnings from ResourceReader, even if BinaryFormatter is enabled. Then we would just need to define somewhere (probably in the SDK) the set of features which are incompatible with trimming, and warn if any of these are enabled.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment