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
Use stricter rule to avoid unnecessary unwrapping of PSObject when operating on a COM object #4614
Conversation
var baseValue = PSObject.Base(args[i].Value); | ||
if (baseValue != args[i].Value) | ||
bool checkBaseTypeForRestriction = false; | ||
if (i == 0) { checkBaseTypeForRestriction = moreRestrictionForArg0; } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We could exclude this by call overload DeferForPSObject
if args.Length == 1
before the cycle.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@iSazonov - that is a good suggestion. Alternatively, I prefer:
bool checkBaseTypeForRestriction = i == 0 && moreRestrictionForArg0;
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
My thoughts were - if args.Length usually much more 1 it is better to move the "initialization" out of cycle.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I considered bool checkBaseTypeForRestriction = i == 0 && moreRestrictionForArg0;
when working on the changes, but that menas "1 'comparison'
and 1 'and'
operations" for every arg. I felt that maybe a 'i==0'
check is less overhead ... But I might overthink about it 😄 Will change it.
@iSazonov We only want to use moreRestrictionForArg0
for the first element of args
. For the rest of elements, we just pass in false
.
Are you sure this is the right fix? The original ideal behind |
@lzybkr The restrictions originally used in |
In that case, the fix is not correct, consider something like: foreach ($o in & { New-Object string "a",5; New-Object -ComObject Something }) {
$o.DoSomething()
} The first time hitting the site invoking The second time hitting the site, the previous rule will succeed and you won't generate a new rule. I don't see how avoiding unwrapping fixes a bug - isn't it possible that a site might receive the COM object unwrapped - and assuming so, shouldn't it behave the same as if the same instance is wrapped? To be concrete, the following should work the same, right? $o = New-Object -COM Something
($o, $o.psobject.BaseObject) | % { $_.DoSomething() } |
Example 1 from Jason's commentforeach ($o in & { New-Object string "a",5; New-Object -ComObject Something }) {
$o.DoSomething()
} For the string wrapped in PSObject, the code path won't fall in if ((baseObject != null) && (baseObject.GetType().FullName.Equals("System.__ComObject")))
{
return this.DeferForPSObject(args.Prepend(target).ToArray()).WriteToDebugLog(this);
} So when it's the second time hitting the site (the COM object), the previous rule won't succeed. This is what I get with the fix of this PR: PS:1> foreach ($o in & { New-Object string "a",5; New-Object -ComObject "Shell.Application" }) {
>> $o.Windows()
>> }
Method invocation failed because [System.String] does not contain a method named 'Windows'.
At line:2 char:1
+ $o.Windows()
+ ~~~~~~~~~~~~
+ CategoryInfo : InvalidOperation: (:) [], RuntimeException
+ FullyQualifiedErrorId : MethodNotFound
Application : System.__ComObject
Parent : System.__ComObject
Container :
Document : System.__ComObject
TopLevelContainer : True
Type :
Left : 398
Top : 316
Width : 2034
Height : 1158
LocationName : Quick access
LocationURL :
Busy : False
Name : File Explorer
HWND : 656452
FullName : C:\windows\Explorer.EXE
Path : C:\windows\
... Example 2 from Jason's comment
$o = New-Object -COM Something
($o, $o.psobject.BaseObject) | % { $_.DoSomething() } Well, this example may not be ideal because object will be wrapped into PSObject when passed to $o = New-Object -COM Something
$o.psobject.BaseObject.DoSomething
$o | % { $_.DoSomething } This example might be what you meant to have, the first call happens on a COM object directly, and the the second happens on a PSObject. In the first time, rule A was generated with a check of My additional commentsThe additional restriction is not added to all cases where we need to unwrap PSObject, but only when it's needed. More specifically, for GetMember/SetMember/InvokeMember binders, PSObject should be unwrapped only when the target's base is a COM object, not any other PSObject. Take the following code as an instance: $o = New-Object -COM "Shell.Application"
$str = Add-Member -InputObject $str -MemberType ScriptMethod -Name Windows -Value { "Windows" } -PassThru
$o | % { $_.Windows() }
$str.Windows() The first call will fall into |
I think I understand the issue now. We unwrap PSObject wrapped com objects only - because the COM binding code from C# does not expect PSObject and we did not update that code to generate correct restrictions and unwrap as needed when seeing PSObject. The restrictions created by DeferForPS were intentionally not checking the type because that would create too many dynamic methods. I believe the restrictions should mirror the test that sends us down the path of deferring - namely that the base object is a COM object. This is more general than what you have in the fix right now which is checking the exact type. So basically - call |
It "GetMember binder should differentiate PSObject that wraps COM object from other PSObjects" { | ||
## GetMember on the member name 'Name'. | ||
## '$_' here is a PSObject that wraps a COM object | ||
$item | ForEach-Object { $_.Name } > $null |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think it's useful to test with a single dynamic site, something like this:
$t1 = ($item, "?")
$t2 = ($str, "Hello")
for ($pair in ($t1, $t2, $t2, $t1, $t1, $t2)) {
$pair[0].Name | Should Be $pair[1]
}
It's not much different from what you have, but this way you know it's the site's cache that influences your test and not the process wide cache, plus you can test all the rule orderings explicitly.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixed.
@@ -338,32 +338,82 @@ internal static string ToDebugString(this BindingRestrictions restrictions) | |||
|
|||
internal static class DynamicMetaObjectBinderExtensions | |||
{ | |||
internal static DynamicMetaObject DeferForPSObject(this DynamicMetaObjectBinder binder, | |||
params DynamicMetaObject[] args) | |||
internal static DynamicMetaObject DeferForPSObject(this DynamicMetaObjectBinder binder, DynamicMetaObject arg0, bool moreRestrictionForArg0 = false) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
For consistency, arg0
should be renamed target
because we only care about the target.
I also think restrictions are specific to COM so the optional parameter could be named targetIsComObject
.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixed.
I think checking
The As for GetMember/SetMember/InvokeMember, since we will defer only if the base of target is COM object, checking the base object type in restriction won't result in multiple dynamic methods because the rule will be |
Strictly speaking, It is used in many of the binders because those binders see fewer instances of PSObject and it was a trade off between generating the best possible code and just getting things to work correctly. In the case of Get/Set/Invoke Member - instances of PSObject are very common, so In hindsight, it might not be a ton of work to eliminate In general, there should be no difference between a wrapped object and an unwrapped object - we need to generate different code, but unwrapping shouldn't gives us a different result. I believe strings with instance members are the only common exception to this statement (it can be an issue for value types as well, but because of boxing and unboxing, it is not a common problem.) So barring an example that proves otherwise, I think the other sites that call As for the generated restrictions for COM objects - the code you have now will unnecessary test And from my limited understanding of COM - I thought strongly typed wrappers are possible (commonly generated with |
Use the same example about
Since the previous rule succeeds for the second call, target and all args will be unwrapped. If one of the args is a PSObject that wraps a string with ETS instance members, then the members will be stripped after adding to the collection. So theoretically, this is affected, though I don't have real code to show this issue. Do you think we should consider this case? |
You are absolutely right about this! This is what I got when using the excel COM interop assembly:
So yes, a simple TypeEqual is not sufficient. I will use |
Like I said - I think it's a non-issue because the instance members don't matter when adding strings - we'll never attempt to access the instance members that we wouldn't find, and the unwrapped instance is not saved anywhere, so I think there is no bug there. |
Actually it probably is sufficient, but we would generate dynamic methods with the identical expressions and differing constraints - maybe fine if the constraint was expensive, but with the added cost of extra jit time and extra memory. |
It would be sufficient if we don't change the current COM checking code in GetMember/SetMember/InvokeMember, because currently we don't even consider the strongly types RWCs. If we update the COM checking code to use |
@lzybkr I think your comments are all addressed. Can you please take another look? |
.Merge(args[i].GetSimpleTypeRestriction()) | ||
.Merge(BindingRestrictions.GetExpressionRestriction(Expression.NotEqual(exprs[i], args[i].Expression))); | ||
.Merge(arg.GetSimpleTypeRestriction()) | ||
.Merge(BindingRestrictions.GetExpressionRestriction(Expression.Call(CachedReflectionInfo.Utils_IsComObject, expr))); | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Here expr
is not a PSObject, and IsComObject calls PSObject.BaseObject.
A clean fix is to introduce an overload on IsComObject, one accepting PSObject and one that accepts object, calling the later here.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The IsComObject
method is also used in FallbackConvert
, and that target.Expression
there could represent either
a COM object or a PSObject wrapping a COM object. Are you suggesting to make the expression restriction
a bit complex by changing it to '(target.expression is PSObject AND IsComObject(PSObject target.Expression)) OR (IsComObject(Object target.Expression))'
?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think it's better to avoid the more complex version of 'expression restriction'
for FallbackConvert
. We can do one of the following two:
internal static bool IsComObject(object obj)
{
if (obj is PSObject) { obj = PSObject.Base(obj); }
return obj != null && ComObjectType.IsAssignableFrom(obj.GetType());
}
OR
internal static bool IsComObject(object obj, bool mayBeWrapped)
{
if (mayBeWrapped) { obj = PSObject.Base(obj); }
return obj != null && ComObjectType.IsAssignableFrom(obj.GetType());
}
I vote for the first one.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In that case, I vote for neither - I'd leave it or introduce another helper with a different name.
I tend to err on the side of less general code to get a faster test, but it probably doesn't matter much anyway.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
OK, I will leave the PR as is.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actually, we can have IsComObject(PSObject)
and IsComObject(object)
, and make the 'expression restriction'
in FallbackConvert
look like this: IsComObject(PSObject.Base(target.Expression))
, it makes the restriction a little bit more complex, but not much. I will go with it, let me know what you think.
var baseValue = PSObject.Base(args[i].Value); | ||
if (baseValue != args[i].Value) | ||
// Target maps to arg[0] of the binder. | ||
bool argIsComObject = (i == 0) && targetIsComObject; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
FWIW, I believe this is what @iSazonov had in mind (and I don't care much one way or the other to be honest):
exprs[0] = ProcessOnePSObject(args[0], ref restrictions, targetIsComObject);
for (int i = 1; i < args.Length; i++)
{
exprs[i] =ProcessOnePSObject(args[i], ref restrictions, argIsComObject: false);
}
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I see. It actually will need a bit more code -- check if args.Length > 0
because args[0]
could fail otherwise. Since the perf is not a concern here, I will keep it as is.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When would args.Length == 0
? That seems like an impossible condition, we wouldn't have anything to defer.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We already assumed args
is not null in this method, so I guess it's OK to assume 'args.Length > 0'.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixed.
@iSazonov Thanks for the review! |
Fix #4607
Summary
GetMember/SetMember/InvokeMember operations on a COM object will generate code to unwrap the COM object if it's wrapped in PSObject. Due to a loose restriction, if you have a string wrapped to a PSObject with a ETS member of the the same name, then accessing that member will fail.
Fix
Use a more restricted rule by checking the base object type when it's necessary.