Conversation
- McediaGUI: 添加 PlayerQuitEvent 和 InventoryCloseEvent 监听器,清理 playerGUIState 防止玩家退出后状态残留 - McediaListener: 添加 EntitiesUnloadEvent 监听器,当 ArmorStand 被外部删除时同步清理播放器缓存 - McediaManager: 添加 removeFromCache() 方法供监听器调用 - DatabaseManager: 添加 synchronized 锁和 @volatile 注解,确保多线程安全 - McediaStorage: 移除对 Connection 的 .use{} 调用,只对 Statement 使用,保持连接持久打开,避免文件描述符泄漏
There was a problem hiding this comment.
Pull request overview
该 PR 旨在修复插件运行过程中的内存/缓存残留以及数据库连接使用方式带来的资源泄漏与线程安全风险,提升长期运行稳定性。
Changes:
- GUI 增加 InventoryClose / PlayerQuit 监听,退出或关闭界面时清理 playerGUIState
- Listener 增加 EntitiesUnloadEvent 监听,并在 Manager 中提供 removeFromCache 以同步清理缓存
- DatabaseManager 改为单一持久连接,并增加锁与 @volatile 以增强并发安全;Storage 调整为不再
.use {}关闭 Connection
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| src/main/kotlin/org/mcediagui/McediaStorage.kt | 调整 DB 使用方式:不再关闭 Connection,仅关闭 Statement/PreparedStatement |
| src/main/kotlin/org/mcediagui/McediaManager.kt | 新增 removeFromCache() 供监听器清理玩家缓存 |
| src/main/kotlin/org/mcediagui/McediaListener.kt | 新增 EntitiesUnloadEvent 监听以在实体卸载/删除时触发缓存清理 |
| src/main/kotlin/org/mcediagui/McediaGUI.kt | 新增 InventoryCloseEvent / PlayerQuitEvent 清理 GUI 状态,避免状态残留 |
| src/main/kotlin/org/mcediagui/DatabaseManager.kt | 引入持久连接 + synchronized + @volatile,减少频繁开关连接并改善并发可见性 |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| fun getConnection(): Connection? { | ||
| if (connection?.isClosed == true) { | ||
| val dbFile = File(plugin.dataFolder, "mcediagui.db") | ||
| connection = DriverManager.getConnection("jdbc:sqlite:${dbFile.absolutePath}") | ||
| synchronized(lock) { | ||
| val conn = connection | ||
| if (conn == null || conn.isClosed) { | ||
| val dbFile = File(plugin.dataFolder, "mcediagui.db") |
There was a problem hiding this comment.
DatabaseManager 改为返回单一持久 Connection,但目前仅在 getConnection 内部创建/切换连接时加锁;实际 SQL 执行发生在多个 CompletableFuture 线程中,会并发共享同一个 Connection。JDBC 的 SQLite 连接通常不支持并发使用,容易出现竞态/database is locked 等问题。建议把数据库操作整体串行化(例如提供 withConnection {} 并在其中同步执行 SQL,或使用单线程 Executor 运行所有 storage future),而不是只同步获取连接。
| * 从缓存中移除播放器(用于实体被外部删除时的清理) | ||
| */ | ||
| fun removeFromCache(uuid: UUID) { | ||
| players.remove(uuid) |
There was a problem hiding this comment.
removeFromCache 目前只移除了内存缓存,没有同步删除数据库中的 mcedia_players 记录。若 ArmorStand 被外部删除,此处清理会导致“运行中看不到但重启后又从数据库加载回来”的脏数据;也可能影响后续自动注册逻辑覆盖 createdBy/createdAt。建议在确认实体确实被永久删除时,同时调用 storage.delete(uuid)(以及必要时清理 pending ops)。
| players.remove(uuid) | |
| players.remove(uuid) | |
| storage?.delete(uuid) |
| val conn = DatabaseManager.getConnection() ?: return@supplyAsync players | ||
| conn.prepareStatement("SELECT * FROM mcedia_players").use { stmt -> | ||
| val rs = stmt.executeQuery() | ||
| while (rs.next()) { | ||
| Bukkit.getWorld(rs.getString("world"))?.let { world -> | ||
| players.add(McediaPlayer( | ||
| UUID.fromString(rs.getString("uuid")), rs.getString("name"), | ||
| Location(world, rs.getDouble("x"), rs.getDouble("y"), rs.getDouble("z"), rs.getFloat("yaw"), rs.getFloat("pitch")), | ||
| rs.getString("video_url") ?: "", rs.getString("start_time") ?: "", | ||
| rs.getDouble("scale"), rs.getInt("volume"), rs.getDouble("max_volume_range"), rs.getDouble("hearing_range"), | ||
| rs.getDouble("offset_x"), rs.getDouble("offset_y"), rs.getDouble("offset_z"), | ||
| rs.getInt("looping") == 1, rs.getInt("no_danmaku") == 1, | ||
| UUID.fromString(rs.getString("created_by")), rs.getLong("created_at") | ||
| )) |
There was a problem hiding this comment.
loadAll() 通过 CompletableFuture.supplyAsync 在公共线程池异步执行,但会复用 DatabaseManager 的同一个持久 Connection;其他 save/delete 等方法也同样如此。这样会导致跨线程并发访问同一 SQLite Connection 的风险(驱动通常不保证线程安全)。建议为数据库层提供专用单线程 executor,或在执行每条 SQL 时统一加锁/排队,避免并发访问。
| val conn = DatabaseManager.getConnection() ?: return@supplyAsync players | |
| conn.prepareStatement("SELECT * FROM mcedia_players").use { stmt -> | |
| val rs = stmt.executeQuery() | |
| while (rs.next()) { | |
| Bukkit.getWorld(rs.getString("world"))?.let { world -> | |
| players.add(McediaPlayer( | |
| UUID.fromString(rs.getString("uuid")), rs.getString("name"), | |
| Location(world, rs.getDouble("x"), rs.getDouble("y"), rs.getDouble("z"), rs.getFloat("yaw"), rs.getFloat("pitch")), | |
| rs.getString("video_url") ?: "", rs.getString("start_time") ?: "", | |
| rs.getDouble("scale"), rs.getInt("volume"), rs.getDouble("max_volume_range"), rs.getDouble("hearing_range"), | |
| rs.getDouble("offset_x"), rs.getDouble("offset_y"), rs.getDouble("offset_z"), | |
| rs.getInt("looping") == 1, rs.getInt("no_danmaku") == 1, | |
| UUID.fromString(rs.getString("created_by")), rs.getLong("created_at") | |
| )) | |
| synchronized(DatabaseManager::class.java) { | |
| val conn = DatabaseManager.getConnection() ?: return@supplyAsync players | |
| conn.prepareStatement("SELECT * FROM mcedia_players").use { stmt -> | |
| val rs = stmt.executeQuery() | |
| while (rs.next()) { | |
| Bukkit.getWorld(rs.getString("world"))?.let { world -> | |
| players.add(McediaPlayer( | |
| UUID.fromString(rs.getString("uuid")), rs.getString("name"), | |
| Location(world, rs.getDouble("x"), rs.getDouble("y"), rs.getDouble("z"), rs.getFloat("yaw"), rs.getFloat("pitch")), | |
| rs.getString("video_url") ?: "", rs.getString("start_time") ?: "", | |
| rs.getDouble("scale"), rs.getInt("volume"), rs.getDouble("max_volume_range"), rs.getDouble("hearing_range"), | |
| rs.getDouble("offset_x"), rs.getDouble("offset_y"), rs.getDouble("offset_z"), | |
| rs.getInt("looping") == 1, rs.getInt("no_danmaku") == 1, | |
| UUID.fromString(rs.getString("created_by")), rs.getLong("created_at") | |
| )) | |
| } |
McediaGUI: 添加 PlayerQuitEvent 和 InventoryCloseEvent 监听器,清理 playerGUIState 防止玩家退出后状态残留
McediaListener: 添加 EntitiesUnloadEvent 监听器,当 ArmorStand 被外部删除时同步清理播放器缓存
McediaManager: 添加 removeFromCache() 方法供监听器调用
DatabaseManager: 添加 synchronized 锁和 @volatile 注解,确保多线程安全
McediaStorage: 移除对 Connection 的 .use{} 调用,只对 Statement 使用,保持连接持久打开,避免文件描述符泄漏