-
Notifications
You must be signed in to change notification settings - Fork 0
Fix topological sort violating hook dependencies by using Kahn's algorithm with priority tie-breaking #191
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
Changes from all commits
0471e83
ea677c5
1a1a0d3
0d30fd4
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -2,7 +2,9 @@ package dev.slne.surf.surfapi.core.server.hook | |
|
|
||
| import com.github.benmanes.caffeine.cache.Caffeine | ||
| import com.sksamuel.aedile.core.asLoadingCache | ||
| import dev.slne.surf.surfapi.core.api.util.mutableObject2IntMapOf | ||
| import dev.slne.surf.surfapi.core.api.util.mutableObject2ObjectMapOf | ||
| import dev.slne.surf.surfapi.core.api.util.mutableObjectListOf | ||
| import dev.slne.surf.surfapi.core.api.util.mutableObjectSetOf | ||
| import dev.slne.surf.surfapi.core.api.util.requiredService | ||
| import dev.slne.surf.surfapi.shared.api.hook.Hook | ||
|
|
@@ -13,6 +15,7 @@ import dev.slne.surf.surfapi.shared.internal.hook.PluginHookMeta | |
| import kotlinx.serialization.SerializationException | ||
| import net.kyori.adventure.text.logger.slf4j.ComponentLogger | ||
| import java.io.InputStream | ||
| import java.util.PriorityQueue | ||
|
|
||
| abstract class HookService { | ||
|
|
||
|
|
@@ -179,54 +182,89 @@ abstract class HookService { | |
| return emptyList() | ||
| } | ||
|
|
||
| val sorted = mutableListOf<Hook>() | ||
| val visited = mutableSetOf<String>() | ||
| val visiting = mutableSetOf<String>() | ||
|
|
||
| fun visit(className: String) { | ||
| if (className in visited) return | ||
| // Build dependency graph: className -> list of dependents (successors) | ||
| // Note: In Kahn's algorithm, edges go from dependency to dependent | ||
| val graph = mutableObject2ObjectMapOf<String, MutableList<String>>() | ||
| for ((meta, _) in validHooks) { | ||
| // Ensure all nodes exist in the graph | ||
| if (meta.className !in graph) { | ||
| graph[meta.className] = mutableListOf() | ||
| } | ||
| // Add edges from dependencies to this hook | ||
| for (depClassName in meta.hookDependencies) { | ||
| if (depClassName in hooksByClassName) { | ||
| graph.computeIfAbsent(depClassName) { mutableListOf() }.add(meta.className) | ||
| } | ||
| } | ||
| } | ||
|
|
||
| if (className in visiting) { | ||
| val chain = buildCircularDependencyChain(className, metaByClassName, visiting) | ||
| throw IllegalStateException( | ||
| "Circular hook dependency detected: ${chain.joinToString(" -> ")}" | ||
| ) | ||
| // Kahn's algorithm with priority queue for tie-breaking | ||
| val incomingEdges = mutableObject2IntMapOf<String>() | ||
| for ((vertex, successors) in graph) { | ||
| if (vertex !in incomingEdges) { | ||
| incomingEdges[vertex] = 0 | ||
| } | ||
| for (successor in successors) { | ||
| incomingEdges.mergeInt(successor, 1, Int::plus) | ||
| } | ||
| } | ||
|
|
||
| visiting.add(className) | ||
| // Use a priority queue ordered by hook priority (lower priority value = higher priority) | ||
| val queue = PriorityQueue<String>(compareBy { className -> | ||
| hooksByClassName[className]?.priority ?: Short.MAX_VALUE | ||
| }) | ||
|
|
||
| incomingEdges.object2IntEntrySet().fastForEach { entry -> | ||
| val vertex = entry.key | ||
| val edges = entry.intValue | ||
| if (edges == 0) queue += vertex | ||
| } | ||
|
|
||
| val dependencies = metaByClassName[className]?.hookDependencies ?: emptyList() | ||
| for (depClassName in dependencies) { | ||
| if (depClassName in hooksByClassName) { | ||
| visit(depClassName) | ||
| val result = mutableObjectListOf<Hook>() | ||
|
|
||
| while (queue.isNotEmpty()) { | ||
| val vertex = queue.poll() | ||
| hooksByClassName[vertex]?.let { result += it } | ||
|
|
||
| for (successor in graph[vertex].orEmpty()) { | ||
| incomingEdges.mergeInt(successor, -1, Int::minus) | ||
| if (incomingEdges.getInt(successor) == 0) { | ||
| queue += successor | ||
| } | ||
| } | ||
| } | ||
|
|
||
| visiting.remove(className) | ||
| visited.add(className) | ||
|
|
||
| hooksByClassName[className]?.let { sorted.add(it) } | ||
| if (result.size != incomingEdges.size) { | ||
| val chain = findCyclicDependency(graph, incomingEdges) | ||
| throw IllegalStateException( | ||
| "Circular hook dependency detected: ${chain.joinToString(" -> ")}" | ||
| ) | ||
| } | ||
|
|
||
| validHooks.forEach { (meta, _) -> visit(meta.className) } | ||
| return sorted.sortedBy { it.priority } | ||
| return result | ||
| } | ||
|
|
||
| private fun buildCircularDependencyChain( | ||
| startClassName: String, | ||
| metaByClassName: Map<String, PluginHookMeta.Hook>, | ||
| visiting: Set<String> | ||
| private fun findCyclicDependency( | ||
| graph: Map<String, List<String>>, | ||
| incomingEdges: Map<String, Int> | ||
| ): List<String> { | ||
| // Find a node that still has incoming edges (part of cycle) | ||
| val cycleNode = incomingEdges.entries.firstOrNull { it.value > 0 }?.key | ||
| ?: return emptyList() | ||
|
|
||
| // Trace back through dependencies to find the cycle | ||
| val visited = mutableSetOf<String>() | ||
| val chain = mutableListOf<String>() | ||
| var current = startClassName | ||
| var current = cycleNode | ||
|
|
||
| while (current !in chain) { | ||
| while (current !in visited) { | ||
| visited.add(current) | ||
| chain.add(current) | ||
| val deps = metaByClassName[current]?.hookDependencies ?: break | ||
| current = deps.firstOrNull { it in visiting } ?: break | ||
| // Find a successor that still has incoming edges (part of cycle) | ||
| current = graph[current]?.firstOrNull { incomingEdges[it] ?: 0 > 0 } ?: break | ||
| } | ||
|
Comment on lines
+255
to
265
|
||
|
|
||
| chain.add(startClassName) | ||
| chain.add(current) | ||
| return chain | ||
| } | ||
|
|
||
|
|
||
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 condition
if (depClassName in hooksByClassName)checks against all hooks, but should check against validHooks only. If a dependency exists in hooksByClassName but was filtered out of validHooks (due to missing dependencies), it will create a graph node that doesn't correspond to a valid hook. This causes the algorithm to fail because the graph will have nodes that don't represent actual processable hooks. The check should ensure the dependency is in the validHooks set.