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

Reduce the number of retain cycles between host and guest #2029

Merged
merged 3 commits into from
May 15, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ Changed:
- Removed deprecated `TreehouseAppFactory` functions with the old `FileSystem` and `Path` order which were changed in 0.11.0.

Fixed:
- Nothing yet!
- Fix memory leaks caused by reference cycles on iOS. We got into trouble mixing garbage-collected Kotlin objects with reference-counted Swift objects.


## [0.11.0] - 2024-05-15
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ public abstract interface class app/cash/redwood/protocol/host/GeneratedProtocol

public final class app/cash/redwood/protocol/host/ProtocolBridge : app/cash/redwood/protocol/ChangesSink {
public synthetic fun <init> (Ljava/lang/String;Lapp/cash/redwood/widget/Widget$Children;Lapp/cash/redwood/protocol/host/ProtocolFactory;Lapp/cash/redwood/protocol/EventSink;Lkotlin/jvm/internal/DefaultConstructorMarker;)V
public final fun close ()V
public fun sendChanges (Ljava/util/List;)V
}

Expand Down
1 change: 1 addition & 0 deletions redwood-protocol-host/api/jvm/redwood-protocol-host.api
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ public abstract interface class app/cash/redwood/protocol/host/GeneratedProtocol

public final class app/cash/redwood/protocol/host/ProtocolBridge : app/cash/redwood/protocol/ChangesSink {
public synthetic fun <init> (Ljava/lang/String;Lapp/cash/redwood/widget/Widget$Children;Lapp/cash/redwood/protocol/host/ProtocolFactory;Lapp/cash/redwood/protocol/EventSink;Lkotlin/jvm/internal/DefaultConstructorMarker;)V
public final fun close ()V
public fun sendChanges (Ljava/util/List;)V
}

Expand Down
1 change: 1 addition & 0 deletions redwood-protocol-host/api/redwood-protocol-host.klib.api
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ abstract interface app.cash.redwood.protocol.host/ProtocolMismatchHandler { // a
}
final class <#A: kotlin/Any> app.cash.redwood.protocol.host/ProtocolBridge : app.cash.redwood.protocol/ChangesSink { // app.cash.redwood.protocol.host/ProtocolBridge|null[0]
constructor <init>(app.cash.redwood.protocol/RedwoodVersion, app.cash.redwood.widget/Widget.Children<#A>, app.cash.redwood.protocol.host/ProtocolFactory<#A>, app.cash.redwood.protocol/EventSink) // app.cash.redwood.protocol.host/ProtocolBridge.<init>|<init>(app.cash.redwood.protocol.RedwoodVersion;app.cash.redwood.widget.Widget.Children<1:0>;app.cash.redwood.protocol.host.ProtocolFactory<1:0>;app.cash.redwood.protocol.EventSink){}[0]
final fun close() // app.cash.redwood.protocol.host/ProtocolBridge.close|close(){}[0]
final fun sendChanges(kotlin.collections/List<app.cash.redwood.protocol/Change>) // app.cash.redwood.protocol.host/ProtocolBridge.sendChanges|sendChanges(kotlin.collections.List<app.cash.redwood.protocol.Change>){}[0]
}
final class <#A: kotlin/Any> app.cash.redwood.protocol.host/ProtocolChildren { // app.cash.redwood.protocol.host/ProtocolChildren|null[0]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,11 @@ public class ProtocolBridge<W : Any>(
/** Nodes available for reuse. */
private val pool = ArrayDeque<ProtocolNode<W>>()

private var closed = false

override fun sendChanges(changes: List<Change>) {
check(!closed)

@Suppress("NAME_SHADOWING")
val changes = applyReuse(changes)

Expand Down Expand Up @@ -154,6 +158,16 @@ public class ProtocolBridge<W : Any>(
return checkNotNull(nodes[id]) { "Unknown widget ID ${id.value}" }
}

/**
* Proactively clear held widgets. (This avoids problems when mixing garbage-collected Kotlin
* objects with reference-counted Swift objects.)
*/
public fun close() {
closed = true
nodes.clear()
pool.clear()
}

private fun poolIfReusable(removedNode: ProtocolNode<W>) {
if (removedNode.reuse) {
removedNode.shapeHash = shapeHash(this.factory, removedNode)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -263,7 +263,7 @@ internal class ProtocolButton<W : Any>(
2 -> _widget.enabled(json.decodeFromJsonElement(serializer_1, change.value))
3 -> {
val onClick: (() -> Unit)? = if (change.value.jsonPrimitive.boolean) {
{ eventSink.sendEvent(Event(change.id, EventTag(3))) }
OnClick(json, change.id, eventSink)
} else {
null
}
Expand Down Expand Up @@ -338,6 +338,12 @@ internal fun generateProtocolNode(
var nextSerializerId = 0
val serializerIds = mutableMapOf<TypeName, Int>()

for (trait in properties) {
if (trait is ProtocolEvent) {
addType(generateEventHandler(trait))
}
}

addFunction(
FunSpec.builder("apply")
.addModifiers(OVERRIDE)
Expand Down Expand Up @@ -371,30 +377,25 @@ internal fun generateProtocolNode(
KotlinxSerialization.jsonBoolean,
)
val arguments = mutableListOf<CodeBlock>()
for ((index, parameterFqType) in trait.parameterTypes.withIndex()) {
for (parameterFqType in trait.parameterTypes) {
val parameterType = parameterFqType.asTypeName()
val serializerId = serializerIds.computeIfAbsent(parameterType) {
nextSerializerId++
}
arguments += CodeBlock.of(
"json.encodeToJsonElement(serializer_%L, arg%L)",
serializerId,
index,
)
arguments += CodeBlock.of("serializer_%L", serializerId)
}
if (trait.parameterTypes.isEmpty()) {
beginControlFlow("{")
addStatement(
"%L(json, change.id, eventSink)::invoke",
Copy link
Collaborator

Choose a reason for hiding this comment

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

Uuuuuggghhh I hate it. So wasteful. I wonder if ES6 or K2 or both will remove that implementation limitation for future optimization.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Same.

Copy link
Collaborator

Choose a reason for hiding this comment

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

trait.eventHandlerName,
)
} else {
beginControlFlow("{ %L ->", trait.parameterTypes.indices.map { CodeBlock.of("arg$it") }.joinToCode())
addStatement(
"%L(json, change.id, eventSink, %L)::invoke",
trait.eventHandlerName,
arguments.joinToCode(),
)
}
addStatement(
"eventSink.sendEvent(%T(change.id, %T(%L), listOf(%L)))",
Protocol.Event,
Protocol.EventTag,
trait.tag,
arguments.joinToCode(),
)
endControlFlow()

nextControlFlow("else")
if (trait.isNullable) {
Expand Down Expand Up @@ -491,6 +492,113 @@ internal fun generateProtocolNode(
.build()
}

/** Returns a class name like "OnClick". */
private val ProtocolEvent.eventHandlerName: String
get() = name.replaceFirstChar { it.uppercase() }

/**
* Generates a named event handler class. We do this instead of using a lambda to be explicit in
* which variables are captured by the event handler. (This avoids problems when mixing
* garbage-collected Kotlin objects with reference-counted Swift objects.)
*/
/*
private class OnClick(
private val json: Json,
private val id: Id,
private val eventSink: EventSink,
private val serializer_0: KSerializer<Int>,
private val serializer_1: KSerializer<String>,
) : (Int, String) -> Unit {
override fun invoke(arg0: Int, arg1: String) {
eventSink.sendEvent(
Event(
id,
EventTag(3),
listOf(
json.encodeToJsonElement(serializer_0, arg0),
json.encodeToJsonElement(serializer_1, arg1),
)
)
)
}
}
*/
private fun generateEventHandler(
trait: ProtocolEvent,
): TypeSpec {
val constructor = FunSpec.constructorBuilder()
val invoke = FunSpec.builder("invoke")

val classBuilder = TypeSpec.classBuilder(trait.eventHandlerName)
.addModifiers(PRIVATE)

addConstructorParameterAndProperty(classBuilder, constructor, "json", KotlinxSerialization.Json)
addConstructorParameterAndProperty(classBuilder, constructor, "id", Protocol.Id)
addConstructorParameterAndProperty(classBuilder, constructor, "eventSink", Protocol.EventSink)

val arguments = mutableListOf<CodeBlock>()
for ((index, parameterFqType) in trait.parameterTypes.withIndex()) {
val parameterType = parameterFqType.asTypeName()
val serializerType = KotlinxSerialization.KSerializer.parameterizedBy(parameterType)
val serializerId = "serializer_$index"
val parameterName = "arg$index"

addConstructorParameterAndProperty(classBuilder, constructor, serializerId, serializerType)
invoke.addParameter(ParameterSpec(parameterName, parameterType))

arguments += CodeBlock.of(
"json.encodeToJsonElement(%L, %L)",
serializerId,
parameterName,
)
}

if (arguments.isEmpty()) {
invoke.addCode(
"eventSink.sendEvent(%T(id, %T(%L)))",
Protocol.Event,
Protocol.EventTag,
trait.tag,
)
} else {
invoke.addCode(
"eventSink.sendEvent(⇥\n%T(⇥\nid,\n%T(%L),\nlistOf(⇥\n%L,\n⇤),\n⇤),\n⇤)",
Protocol.Event,
Protocol.EventTag,
trait.tag,
arguments.joinToCode(separator = ",\n"),
)
}

classBuilder.primaryConstructor(constructor.build())
classBuilder.addFunction(invoke.build())
return classBuilder.build()
}

/** Adds a constructor parameter and property with the same name. */
private fun addConstructorParameterAndProperty(
classBuilder: TypeSpec.Builder,
constructorBuilder: FunSpec.Builder,
name: String,
type: TypeName,
) {
constructorBuilder.addParameter(
ParameterSpec(
name = name,
type = type,
),
)

classBuilder.addProperty(
PropertySpec.builder(
name = name,
type = type,
modifiers = listOf(PRIVATE),
).initializer(name)
.build(),
)
}

internal fun generateProtocolModifierImpls(
schema: ProtocolSchema,
host: ProtocolSchema = schema,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,10 @@ import app.cash.redwood.widget.Widget
import app.cash.zipline.ZiplineApiMismatchException
import app.cash.zipline.ZiplineScope
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.MutableStateFlow
Expand Down Expand Up @@ -302,7 +305,7 @@ private class ViewContentCodeBinding<A : AppService>(
private val isInitialLaunch: Boolean,
private val onBackPressedDispatcher: OnBackPressedDispatcher,
firstUiConfiguration: StateFlow<UiConfiguration>,
) : EventSink, ChangesSinkService, TreehouseView.SaveCallback, ZiplineTreehouseUi.Host {
) : ChangesSinkService, TreehouseView.SaveCallback, ZiplineTreehouseUi.Host {
private val uiConfigurationFlow = SequentialStateFlow(firstUiConfiguration)

private val bindingScope = CoroutineScope(
Expand All @@ -324,6 +327,9 @@ private class ViewContentCodeBinding<A : AppService>(
/** Only accessed on [TreehouseDispatchers.zipline]. Null after [cancel]. */
private var treehouseUiOrNull: ZiplineTreehouseUi? = null

/** Note that this is necessary to break the retain cycle between host and guest. */
private val eventBridge = EventBridge(dispatchers.zipline, bindingScope)

/** Only accessed on [TreehouseDispatchers.ui]. Empty after [initView]. */
private val changesAwaitingInitView = ArrayDeque<List<Change>>()

Expand Down Expand Up @@ -360,15 +366,6 @@ private class ViewContentCodeBinding<A : AppService>(
}
}

/** Send an event from the UI to Zipline. */
override fun sendEvent(event: Event) {
// Send UI events on the zipline dispatcher.
bindingScope.launch(dispatchers.zipline) {
val treehouseUi = treehouseUiOrNull ?: return@launch
treehouseUi.sendEvent(event)
}
}

/** Send changes from Zipline to the UI. */
override fun sendChanges(changes: List<Change>) {
// Receive UI updates on the UI dispatcher.
Expand Down Expand Up @@ -412,7 +409,7 @@ private class ViewContentCodeBinding<A : AppService>(
json = codeSession.json,
protocolMismatchHandler = eventPublisher.widgetProtocolMismatchHandler,
) as ProtocolFactory<Any>,
eventSink = this,
eventSink = eventBridge,
)
bridgeOrNull = bridge
}
Expand All @@ -430,6 +427,7 @@ private class ViewContentCodeBinding<A : AppService>(
val scopedAppService = serviceScope.apply(codeSession.appService)
val treehouseUi = contentSource.get(scopedAppService)
treehouseUiOrNull = treehouseUi
eventBridge.delegate = treehouseUi
stateSnapshot = viewOrNull?.stateSnapshotId?.let {
stateStore.get(it.value.orEmpty())
}
Expand Down Expand Up @@ -488,16 +486,47 @@ private class ViewContentCodeBinding<A : AppService>(
}
}

@OptIn(ExperimentalCoroutinesApi::class)
fun cancel() {
dispatchers.checkUi()
canceled = true
viewOrNull?.saveCallback = null
viewOrNull = null
bridgeOrNull?.close()
bridgeOrNull = null
bindingScope.launch(dispatchers.zipline) {
eventBridge.bindingScope = null
eventBridge.ziplineDispatcher = null
bindingScope.launch(dispatchers.zipline, start = CoroutineStart.ATOMIC) {
treehouseUiOrNull = null
eventBridge.delegate = null
serviceScope.close()
bindingScope.cancel()
}
}
}

/**
* Bridge events from the UI dispatcher to the Zipline dispatcher.
*
* Event sinks are in a natural retain cycle between the host and guest. We prevent unwanted
* retain cycles by breaking the link to the delegate when the binding is canceled. This avoids
* problems when mixing garbage-collected Kotlin objects with reference-counted Swift objects.
*/
private class EventBridge(
// Both properties are only accessed on the UI dispatcher and null after cancel().
var ziplineDispatcher: CoroutineDispatcher?,
var bindingScope: CoroutineScope?,
) : EventSink {
// Only accessed on the Zipline dispatcher and null after cancel().
var delegate: EventSink? = null

/** Send an event from the UI to Zipline. */
override fun sendEvent(event: Event) {
// Send UI events on the zipline dispatcher.
val dispatcher = this.ziplineDispatcher ?: return
val bindingScope = this.bindingScope ?: return
bindingScope.launch(dispatcher) {
delegate?.sendEvent(event)
}
}
}