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

Don’t force a suspend #844

Merged
merged 14 commits into from
Jan 12, 2023
Merged

Don’t force a suspend #844

merged 14 commits into from
Jan 12, 2023

Conversation

swankjesse
Copy link
Contributor

Closes: #826

@JakeWharton
Copy link
Member

Requires more than a quick perusal so I'll look later.

* Immediate result from invoking a suspending function, that may include either a value (if the
* call never suspended) or a cancel callback (if it did suspend).
*/
internal class SuspendingResult<T>(
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Considering for follow-up . . .

We have a bunch of result types:

  • kotlin.Result - success or failure
  • app.cash.zipline.CallResult - success or failure, encoded JSON, service names
  • app.cash.zipline.internal.bridge.SuspendingResult - success or failure or callback
  • app.cash.zipline.internal.bridge.EncodedSuspendingResult - success or failure or callback, encoded JSON, service names

We also have serializers for kotlin.Result and SuspendingResult.

Some options to reduce how much code we need:

  • Combine the two serializers to save a bit of code. Rename SuspendingResult to InternalResult for symmetry with InternalCall.
  • Change EncodedSuspendingResult to have a kotlin.Result as a field

Copy link
Member

@JakeWharton JakeWharton left a comment

Choose a reason for hiding this comment

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

Cool stuff


val cancelCallback = object : CancelCallback, HasPassByReferenceName {
override var passbyReferenceName: String? = null
deferred.invokeOnCompletion {
Copy link
Member

Choose a reason for hiding this comment

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

I'm a bit worried about the Retrofit case here: where isActive returns true but by the time you get to this code the async has completed and thus this lambda will be invoked synchronously (I'm assuming that's the behavior).

It would mean that we invoke the suspendCallback below synchronously from the stackframe which invoked the suspend function (and before we allow it to return a value). The normal coroutine machinery defends against this (which is what was happening in Retrofit).

In our case, to defend against it, I think we need something like the following:

var scheduleCallback = true
deferred.invokeOnCompletion {
  // ..

  if (scheduleCallback) {
    ziplineDispatcher.schedule({ /* callback logic */ })
  } else {
    // callback logic
  }
}
scheduleCallback = false

Copy link
Contributor Author

Choose a reason for hiding this comment

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

It’s single-threaded, so we don‘t need to worry about that.

Copy link
Member

Choose a reason for hiding this comment

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

As in, the async is running on the same dispatcher as this block? That's good.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Exactly.

Comment on lines +129 to +137
val kotlinResult = when {
failure != null -> Result.failure(failure)
else -> deferred.getCompleted()
}

override fun toString() = "CancelCallback/$internalCall"
}
job.invokeOnCompletion {
val name = cancelCallback.passbyReferenceName
if (name != null) endpoint.remove(name)
}
val suspendingResult = when {
kotlinResult.isFailure -> SuspendingResult<Unit>(failure = kotlinResult.exceptionOrNull()!!)
else -> SuspendingResult(success = kotlinResult.getOrNull())
}
Copy link
Member

Choose a reason for hiding this comment

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

These two when's need merged or something. You create the kotlin.Result and then unpack it just to match on it, force unwrap, and then re-wrap it.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Ooooh it’s weirder than that. Added this comment:

      // Convert our Deferred<Result<T>> into a Result<T>, which will be failure if _either_ the
      // Deferred failed or if it contains a failure Result.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

(Is this possible??)

@@ -214,6 +214,7 @@ internal class RealCallSerializer(
return object : SuspendingZiplineFunction<T>(
name = functionName,
argSerializers = listOf(),
resultSerializer = Int.serializer(), // Placeholder; we're only encoding failures.
Copy link
Member

Choose a reason for hiding this comment

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

There's a NothingSerializer coming in the next version of kotlinx.coroutines.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Oooh want.

Comment on lines +306 to +313
/** The function suspended. Only non-null for suspend calls. */
val cancelCallback: CancelCallback? = null,

/** The function failed without suspending. */
val failure: Throwable? = null,

/** The function succeeded without suspending. */
val success: T? = null,
Copy link
Member

Choose a reason for hiding this comment

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

Do you think we need an init { } which enforces the invariant that one (and only one) is set? Deserialization below could create an instance with all nulls, for example.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Good idea. Done.

@swankjesse
Copy link
Contributor Author

Ugh, I’ve got some unwanted concurrency in the tests.

> Process 'Gradle Test Executor 1' finished with non-zero exit value 132

@swankjesse
Copy link
Contributor Author

Okay so eventually I decided to run java without Gradle wrapping it, and that was incredibly helpful because I started getting extra information. The JVM printed this before it crashed:

Illegal instruction: 4

After scouring Stackoverflow for that error I came across this question, which recommended adding -Xss2048k to the Java command. That solved the crashes on x86_64.

It’s Quite Unfortunate that our stacks are deep enough to cause a JVM process crash.

@swankjesse swankjesse merged commit e7f7790 into trunk Jan 12, 2023
@swankjesse swankjesse deleted the jwilson.0110.suspendresult branch January 12, 2023 16:29
swankjesse added a commit that referenced this pull request Jan 28, 2023
This change is implementation-details only and doesn't change the
data passed between caller and target.

Follow-up to #844
swankjesse added a commit that referenced this pull request Jan 28, 2023
This change is implementation-details only and doesn't change the
data passed between caller and target.

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

Successfully merging this pull request may close these issues.

callSuspending() should’t force a suspend
2 participants