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

GrpcWebClientReadableStream: keep falsy data #1230

Merged
merged 6 commits into from
Oct 25, 2022
Merged

Conversation

pro-wh
Copy link
Contributor

@pro-wh pro-wh commented Apr 27, 2022

cross reference #1025

When using a different codec where primitive values are allowed (we use CBOR), the current implementation omits calling the data listeners on falsy values, such as 0 and null. But we'd like to have them.

Is there anything else that relies on the current behavior that omits these values?


Discussion of changes in GrpcWebClientBase:

Current implementation uses setCallback_ in a special "useUnaryResponse" mode. In this mode, additional calls are made to the callback with status and metadata parameters, then a final time with both error and response set to null.

In order to support falsy and even null message, this changes the final callback call to pass true to a new explicit trigger parameter. callback(null, null) now means "no error, and the response was null" (and similar for other falsy responses).

setCallback_ is private, and the only public wrapper does not allow the "useUnaryResponse" mode, so it should be safe to make this change.

@pro-wh
Copy link
Contributor Author

pro-wh commented Apr 27, 2022

oh oops this should skip if deserializing failed. I'll add some logic to do that

added

@pro-wh
Copy link
Contributor Author

pro-wh commented Apr 27, 2022

there are more changes needed in thenableCall

@pro-wh pro-wh marked this pull request as draft April 27, 2022 18:31
@pro-wh pro-wh marked this pull request as ready for review April 27, 2022 19:32
@sampajano
Copy link
Collaborator

Hi! Thanks for the contribution and sorry for the delay!

2 quick questions:

  1. Curious how are you using a custom codec? I'm still somewhat new and didn't know that's possible 😃
  2. Would it be possible for you to showcase your new use case by adding a test?

thanks!

@pro-wh
Copy link
Contributor Author

pro-wh commented May 16, 2022

Hi, thanks for helping out with this.

How to use a custom codec:

sample code https://github.com/oasisprotocol/oasis-sdk/blob/9cfbb9246ba1797e475728a48d580b0eb8a352d3/client-sdk/ts-web/core/src/client.ts#L22

We haven't been using .proto files to define the methods, so we construct a bunch of our own MethodDescriptor objects. Thus all it took for us was to pass the https://github.com/grpc/grpc-web/blob/1.3.1/javascript/net/grpc/web/methoddescriptor.js#L33 responseDeserializerFn

Adding a test

Ya let me try adding one

@sampajano
Copy link
Collaborator

How to use a custom codec:

sample code https://github.com/oasisprotocol/oasis-sdk/blob/9cfbb9246ba1797e475728a48d580b0eb8a352d3/client-sdk/ts-web/core/src/client.ts#L22

We haven't been using .proto files to define the methods, so we construct a bunch of our own MethodDescriptor objects. Thus all it took for us was to pass the https://github.com/grpc/grpc-web/blob/1.3.1/javascript/net/grpc/web/methoddescriptor.js#L33 responseDeserializerFn

Oh aha! Intersting! Thanks for the info! I guess that's one way of using the library (but without the codegen) 😃

I wonder if we mention the use of library in this way on any documentation? Or this is just one creative way you found out about using it? (I'm guess in the latter case I'm not sure if such use will be "officially supported" going forward, even if we could probably fix a few non-harming edge-cases right now) 😃

Adding a test

Ya let me try adding one

thanks!!

@pro-wh
Copy link
Contributor Author

pro-wh commented May 16, 2022

@sampajano
Copy link
Collaborator

Ah thanks for the info!

I did a quick search and found that the majority of mentioning for custom codec is for golang. (there are some mentions on other languages but nothing seems officially documented.) So personally i'd still consider this a borderline edge use case :) Happy to take small patches if it's easy to maintain, but probably no commitment for major feature support going forward :)

@pro-wh
Copy link
Contributor Author

pro-wh commented Sep 23, 2022

aaah I just saw the new release and realized this was still pending

So personally i'd still consider this a borderline edge use case :) Happy to take small patches if it's easy to maintain, but probably no commitment for major feature support going forward :)

we're happy with that too. if you could please review 🙏

@sampajano
Copy link
Collaborator

aaah I just saw the new release and realized this was still pending

aha right i've forgot as well :)

So personally i'd still consider this a borderline edge use case :) Happy to take small patches if it's easy to maintain, but probably no commitment for major feature support going forward :)

we're happy with that too. if you could please review 🙏

thanks for your interest here! On a 2nd thought, I'll need to check with the team on our stance on custom codec before getting back at you. Thanks!

@pro-wh
Copy link
Contributor Author

pro-wh commented Sep 27, 2022

Sounds good, thanks for checking with the team

@sampajano
Copy link
Collaborator

Of course!

Oh and actually, since you'll be depending on MethodDescriptor, i guess this will be related to #1285 -- where we're no longer exposing its API due to internal code size issue (PR here).

But we could explore our options there too..

In the meanwhile, maybe you could rebase your code onto 1.4.0 and see if it breaks you?

Thanks!

@pro-wh
Copy link
Contributor Author

pro-wh commented Sep 27, 2022

interesting, thanks for the heads up. let me experiment with the changes in 1.4.0. if it's only from the typescript headers, we should be fine

@pro-wh
Copy link
Contributor Author

pro-wh commented Sep 27, 2022

update: yes, it does break our .getName callsite. we will be switching back to .name when we upgrade to 1.4.0

@sampajano
Copy link
Collaborator

update: yes, it does break our .getName callsite. we will be switching back to .name when we upgrade to 1.4.0

aha interesting thanks for confirming! So i guess that would work but is somewhat a "hack".. :)

Copy link
Collaborator

@sampajano sampajano left a comment

Choose a reason for hiding this comment

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

Hi Good news: the MethodDescriptor getName() change was submitted and didn't raise any alarm in the past week or two. So I think we can safely proceed with this PR 😃

Comment on lines 136 to 137
} else {
unaryMsg = response;
Copy link
Collaborator

Choose a reason for hiding this comment

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

I'm not sure we should set the unaryMsg on an else {} case — it seems a bit unsafe to set it before checking nullability (even though it might be ok based on existing callers).

Actually.. I'm curious what if you don't make this change? With the existing code, wouldn't the stream.on('end' ...) below eventually trigger a callback(null, null) call which will fall into this else {}clause, which will trigger the resolve(...) call?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I'm not sure we should set the unaryMsg on an else {} case — it seems a bit unsafe to set it before checking nullability (even though it might be ok based on existing callers).

This single callback has to do 5 different jobs in a function.

  1. receive an error
  2. receive the response value
  3. receive the status
  4. receive the metadata
  5. be informed that we have all pieces needed to give the caller the unary response

So far, it has done this by using a giant if ... tree and testing for various arguments' falsiness. Supporting falsy response values would require a way to differentiate job 2 and job 5. This version declares that job 5 has trigger = true, while job 2 has trigger = false, which overall sticks to the existing theme of "one of the args is truthy, just check which one."

As for how can we ensure it's safe: setCallback_ is private and the callback handle is kept private, so we have control over what will ever call it, and we can make sure all callers follow this new protocol in this PR.

Actually.. I'm curious what if you don't make this change? With the existing code, wouldn't the stream.on('end' ...) below eventually trigger a callback(null, null) call which will fall into this else {}clause, which will trigger the resolve(...) call?

If we don't change the protocol:

  1. A falsy response never gets assigned into unaryMsg. I believe our use case is sloppy-tolerant enough not to experience any problems if grpc-web gives undefined instead of 0 or "" or false or null, but giving the right type is still nice.
  2. The resolve would be called twice. I think standards compliant Promise implementations are specified to ignore extra calls, so that shouldn't break anything. But that's a relatively more obscure detail, and I think the code is easier for beginners to verify if it only ever calls resolve once.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

  1. Actually I don't know about the order of calls to this callback. And if I did know the order, I don't know it's an order that gRPC intends to stick to. If job 2 comes before job 4, for example (I vaguely recall seeing something about trailers in gPRC? Maybe grpc-web doesn't support that though.), we'd incorrectly resolve before setting the unary metadata.

Copy link
Collaborator

Choose a reason for hiding this comment

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

Hi! Thanks for the quick reply and sorry for the slow reply.. It took me a while to wrap my head around the code here.. 😅

As for how can we ensure it's safe: setCallback_ is private and the callback handle is kept private, so we have control over what will ever call it, and we can make sure all callers follow this new protocol in this PR.

What you said makes a lot of sense.. I guess i was kind hoping that there's an explicit signal for setting the response (for better robustness).. but given that the response is falsy here, what you have here is probably the best we can do for now :) (Especially given that the "callback" is not exactly "internal" since we reuse the same function for external callers (here) too..)

If we don't change the protocol:

  1. A falsy response never gets assigned into unaryMsg. I believe our use case is sloppy-tolerant enough not to experience any problems if grpc-web gives undefined instead of 0 or "" or false or null, but giving the right type is still nice.
  2. The resolve would be called twice. I think standards compliant Promise implementations are specified to ignore extra calls, so that shouldn't break anything. But that's a relatively more obscure detail, and I think the code is easier for beginners to verify if it only ever calls resolve once.

Thanks so much for explaining! This makes total sense.. :)

3, Actually I don't know about the order of calls to this callback. And if I did know the order, I don't know it's an order that gRPC intends to stick to. If job 2 comes before job 4, for example (I vaguely recall seeing something about trailers in gPRC? Maybe grpc-web doesn't support that though.), we'd incorrectly resolve before setting the unary metadata.

Yeahh i think grpc-web is not using HTTP trailers due to API limitations (see here) so what you've mentioned shouldn't happen. But thanks for the careful considerations :)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

here's something we could do, let me know if this is anymore idiomatic:

we still add an extra parameter, but instead of true = "done" and false = "get the response value," we can have true = "get the response value" and false = "done"

Copy link
Contributor Author

Choose a reason for hiding this comment

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

pushed a version where instead of a having a unaryEndOfStream flag, there's a unaryResponseReceived flag. i.e., now "there's an explicit signal for setting the response."

added doc comment for the useUnaryResponse parameter to setCallback_

@@ -123,18 +123,18 @@ class GrpcWebClientBase {
let unaryStatus;
let unaryMsg;
GrpcWebClientBase.setCallback_(
stream, (error, response, status, metadata) => {
stream, (error, response, status, metadata, trigger) => {
Copy link
Collaborator

Choose a reason for hiding this comment

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

could you rename trigger to a more explicit name?

Maybe unaryEndOfStream?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

renamed.

Copy link
Collaborator

@sampajano sampajano left a comment

Choose a reason for hiding this comment

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

Thanks so much for the change! Makes total sense! Just a few small things around testing! 😃

@@ -75,6 +75,33 @@ testSuite({
assertElementsEquals(DEFAULT_UNARY_HEADER_VALUES, Object.values(headers));
},

async testRpcFalsyResponse() {
Copy link
Collaborator

Choose a reason for hiding this comment

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

Thanks for adding the test :)

I wonder if you could rename this testRpcFalsyResponse_ForNonProtobufDescriptor so it's more clear that this isn't part of our typical protobuf flow?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

sure

Comment on lines 136 to 137
} else {
unaryMsg = response;
Copy link
Collaborator

Choose a reason for hiding this comment

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

Hi! Thanks for the quick reply and sorry for the slow reply.. It took me a while to wrap my head around the code here.. 😅

As for how can we ensure it's safe: setCallback_ is private and the callback handle is kept private, so we have control over what will ever call it, and we can make sure all callers follow this new protocol in this PR.

What you said makes a lot of sense.. I guess i was kind hoping that there's an explicit signal for setting the response (for better robustness).. but given that the response is falsy here, what you have here is probably the best we can do for now :) (Especially given that the "callback" is not exactly "internal" since we reuse the same function for external callers (here) too..)

If we don't change the protocol:

  1. A falsy response never gets assigned into unaryMsg. I believe our use case is sloppy-tolerant enough not to experience any problems if grpc-web gives undefined instead of 0 or "" or false or null, but giving the right type is still nice.
  2. The resolve would be called twice. I think standards compliant Promise implementations are specified to ignore extra calls, so that shouldn't break anything. But that's a relatively more obscure detail, and I think the code is easier for beginners to verify if it only ever calls resolve once.

Thanks so much for explaining! This makes total sense.. :)

3, Actually I don't know about the order of calls to this callback. And if I did know the order, I don't know it's an order that gRPC intends to stick to. If job 2 comes before job 4, for example (I vaguely recall seeing something about trailers in gPRC? Maybe grpc-web doesn't support that though.), we'd incorrectly resolve before setting the unary metadata.

Yeahh i think grpc-web is not using HTTP trailers due to API limitations (see here) so what you've mentioned shouldn't happen. But thanks for the careful considerations :)

});

const response = await new Promise((resolve, reject) => {
client.rpcCall(
Copy link
Collaborator

Choose a reason for hiding this comment

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

Actually, after scratching my head a bit i realized that the main discussion we had was around "thenableCall" but it's not covered in tests here..

If it's not too difficult, i wonder if you could help add 1-2 thenableCall tests to cover the basic cases (e.g. normal response + false response)? Thanks a lot!!

Copy link
Contributor Author

Choose a reason for hiding this comment

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

ya let me try

Copy link
Contributor Author

Choose a reason for hiding this comment

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

added

Copy link
Collaborator

Choose a reason for hiding this comment

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

wow thanks so much for the new tests. Looks awesome! 😃

@pro-wh
Copy link
Contributor Author

pro-wh commented Oct 24, 2022

I'm getting this when I try to run the tests

[16:40:38] I/update - chromedriver: file exists /home/wh0/work/ekiden/grpc-web/packages/grpc-web/node_modules/webdriver-manager/selenium/chromedriver_93.0.4577.63.zip
[16:40:38] I/update - chromedriver: unzipping chromedriver_93.0.4577.63.zip
[16:40:38] I/update - chromedriver: setting permissions to 0755 for /home/wh0/work/ekiden/grpc-web/packages/grpc-web/node_modules/webdriver-manager/selenium/chromedriver_93.0.4577.63
[16:40:38] I/update - chromedriver: chromedriver_93.0.4577.63 up to date
[16:40:38] I/update - selenium standalone: file exists /home/wh0/work/ekiden/grpc-web/packages/grpc-web/node_modules/webdriver-manager/selenium/selenium-server-standalone-3.141.59.jar
[16:40:38] I/update - selenium standalone: selenium-server-standalone-3.141.59.jar up to date
[16:40:54] I/launcher - Running 1 instances of WebDriver
[16:40:54] I/direct - Using ChromeDriver directly...
[16:40:54] E/launcher - session not created: This version of ChromeDriver only supports Chrome version 93
Current browser version is 106.0.5249.119 with binary path /usr/bin/google-chrome

is it erroneously using my systemwide chrome rather than its own version for testing?

@sampajano
Copy link
Collaborator

sampajano commented Oct 25, 2022

I'm getting this when I try to run the tests

[16:40:38] I/update - chromedriver: file exists /home/wh0/work/ekiden/grpc-web/packages/grpc-web/node_modules/webdriver-manager/selenium/chromedriver_93.0.4577.63.zip
[16:40:38] I/update - chromedriver: unzipping chromedriver_93.0.4577.63.zip
[16:40:38] I/update - chromedriver: setting permissions to 0755 for /home/wh0/work/ekiden/grpc-web/packages/grpc-web/node_modules/webdriver-manager/selenium/chromedriver_93.0.4577.63
[16:40:38] I/update - chromedriver: chromedriver_93.0.4577.63 up to date
[16:40:38] I/update - selenium standalone: file exists /home/wh0/work/ekiden/grpc-web/packages/grpc-web/node_modules/webdriver-manager/selenium/selenium-server-standalone-3.141.59.jar
[16:40:38] I/update - selenium standalone: selenium-server-standalone-3.141.59.jar up to date
[16:40:54] I/launcher - Running 1 instances of WebDriver
[16:40:54] I/direct - Using ChromeDriver directly...
[16:40:54] E/launcher - session not created: This version of ChromeDriver only supports Chrome version 93
Current browser version is 106.0.5249.119 with binary path /usr/bin/google-chrome

is it erroneously using my systemwide chrome rather than its own version for testing?

Aha not sure.. i forgot how i setup my chrome driver.. maybe 93 is a bit too old i can update it soon.. (although i'm using 106.0.5249.119 too and it seems to work..)

Although, if you run the test using ./scripts/run_basic_tests.sh then it'll use docker and will probably work.

Could you try that and see if it works? Thanks :)

Copy link
Collaborator

@sampajano sampajano left a comment

Choose a reason for hiding this comment

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

Thanks SO much for being patient with the reviews! Change looks fantastic!

Thanks again for working on this change! 😃

Comment on lines +227 to +234
* @param {boolean} useUnaryResponse Pass true to have the client make
* multiple calls to the callback, using (error, response, status,
* metadata, unaryResponseReceived) arguments. One of error, status,
* metadata, or unaryResponseReceived will be truthy to indicate which piece
* of information the client is providing in that call. After the stream
* ends, it will call the callback an additional time with all falsy
* arguments. Pass false to have the client make one call to the callback
* using (error, response) arguments.
Copy link
Collaborator

Choose a reason for hiding this comment

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

nice comments.. appreciate it.. 😃

@@ -272,11 +279,11 @@ class GrpcWebClientBase {
message: 'Incomplete response',
});
} else {
callback(null, responseReceived);
callback(null, responseReceived, null, null, /* unaryResponseReceived= */ true);
Copy link
Collaborator

Choose a reason for hiding this comment

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

this is where things get a little bit weird.. since in rpcCall we're directly taking a user-provided callback.. so this implementation is a bit "leaky".. although, probably (hopefully) not a big deal.. 😄

Copy link
Contributor Author

Choose a reason for hiding this comment

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

oh good point. I'll open a follow up not to pass the extra flag when useUnaryResponse is off

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Copy link
Collaborator

Choose a reason for hiding this comment

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

ah! thanks very much for the follow-up fix! 😃

});

const response = await new Promise((resolve, reject) => {
client.rpcCall(
Copy link
Collaborator

Choose a reason for hiding this comment

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

wow thanks so much for the new tests. Looks awesome! 😃

if (error) {
reject(error);
} else if (response) {
} else if (unaryResponseReceived) {
Copy link
Collaborator

Choose a reason for hiding this comment

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

love this change.. looks much cleaner! Thanks!

@sampajano sampajano merged commit e11903b into grpc:master Oct 25, 2022
@sampajano
Copy link
Collaborator

I will cut a new release soon (prob. later this week) with this change. Thanks again! 😃

@sampajano
Copy link
Collaborator

sampajano commented Oct 28, 2022

FYI a new release with this change is cut here :)

https://github.com/grpc/grpc-web/releases/tag/1.4.2

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

Successfully merging this pull request may close these issues.

None yet

3 participants