project-mirai / mirai-api-http

Mirai HTTP API (console) plugin
GNU Affero General Public License v3.0
1.65k stars 345 forks source link

Websocket连接结束如何优雅地关闭该session #636

Closed mayeths closed 1 year ago

mayeths commented 1 year ago

连接mirai的python代码如下,python能发送好友消息成功,也很正常地退出了:

import websocket
import json
import threading

session = None
online = threading.Event()

def on_message(ws, message):
    global session
    print("[message]", message)
    res = json.loads(message)
    if res["data"]["code"] == 0:
        session = res["data"]["session"]
        online.set()

def on_error(ws, err):
    print("[error]", err)
    pass

def on_close(ws, code, msg):
    global session
    print("[closed]", code, msg)
    online.clear()
    session = None

if __name__ == "__main__":
    online.clear()
    ws = websocket.WebSocketApp("ws://127.0.0.1:8888/message?verifyKey=1111222&qq=384123123123",
        on_message = on_message,
        on_error = on_error,
        on_close = on_close,
    )
    wst = threading.Thread(target=ws.run_forever)
    wst.daemon = True
    wst.start()

    online.wait()
    txt = json.dumps({
        "syncId": 123,
        "command": "sendFriendMessage",
        "subCommand": None,
        "content": {
          "sessionKey": session,
          "target": "17123123123",
          "messageChain": [
            { "type": "Plain", "text": "haha" },
          ]
        }
    })
    ws.send(txt)

    ws.close()
    wst.join()

在python代码退出后,mirai有新群消息时就会报错。感觉是mirai没有清掉python对应的socket,怎么让mirai 关闭该socket呢,或者说是BUG?

mirai_issue_mcl2

版本信息:

  15:19:59 [INFO] iTXTech Mirai Console Loader version 2.1.1-d66fada                                                                                                                                                                                                                  
  15:19:59 [INFO] https://github.com/iTXTech/mirai-console-loader                                                                          
  15:19:59 [INFO] This program is licensed under GNU AGPL v3  
  ...
  15:20:03 [INFO] Verifying "net.mamoe:mirai-console" v2.12.1                                                                              
  15:20:04 [INFO] Verifying "net.mamoe:mirai-console-terminal" v2.12.1                                                                     
  15:20:04 [INFO] Verifying "net.mamoe:mirai-core-all" v2.12.1                                                                             
  15:20:04 [INFO] Verifying "org.itxtech:mcl-addon" v2.0.2                                                                                 
  15:20:04 [INFO] Verifying "net.mamoe:mirai-api-http" v2.6.2                                                                                                                                                                                                                         
2022-10-20 15:20:07 I/main: Starting mirai-console...                                                                                                                                                                                                                                 
2022-10-20 15:20:07 I/main: Backend: version 2.12.1, built on 2022-07-31 17:49:27.                                                         
2022-10-20 15:20:07 I/main: Frontend Terminal: version 2.12.1, provided by Mamoe Technologies                                                                                                                                                                                         
2022-10-20 15:20:07 I/main: Welcome to visit https://mirai.mamoe.net/                                                                                                                                                                                                                 
2022-10-20 15:20:08 W/stderr: SLF4J: Failed to load class "org.slf4j.impl.StaticLoggerBinder".                                             
2022-10-20 15:20:08 W/stderr: SLF4J: Defaulting to no-operation (NOP) logger implementation                                                
2022-10-20 15:20:08 W/stderr: SLF4J: See http://www.slf4j.org/codes.html#StaticLoggerBinder for further details.                           
2022-10-20 15:20:10 I/plugin: Successfully loaded plugin net.mamoe.mirai-api-http v2.6.2      
mayeths commented 1 year ago

这是mcl/config/net.mamoe.mirai-api-http/setting.yml的内容:

## 配置文件中的值,全为默认值

## 启用的 adapter, 内置有 http, ws, reverse-ws, webhook
adapters:
  - http
  - ws

## 是否开启认证流程, 若为 true 则建立连接时需要验证 verifyKey
## 建议公网连接时开启
enableVerify: true
verifyKey: 1111222

## 开启一些调式信息
debug: true

## 是否开启单 session 模式, 若为 true,则自动创建 session 绑定 console 中登录的 bot
## 开启后,接口中任何 sessionKey 不需要传递参数
## 若 console 中有多个 bot 登录,则行为未定义
## 确保 console 中只有一个 bot 登陆时启用
singleMode: false

## 历史消息的缓存大小
## 同时,也是 http adapter 的消息队列容量
cacheSize: 4096

## adapter 的单独配置,键名与 adapters 项配置相同
adapterSettings:
  ## 详情看 http adapter 使用说明 配置
  http:
    host: localhost
    port: 8889
    cors: ["*"]

  ## 详情看 websocket adapter 使用说明 配置
  ws:
    host: localhost
    port: 8888
    reservedSyncId: -1
mayeths commented 1 year ago

是的,每次python端这样结束程序后,有消息过来都会打印这个错误,而且多运行几次python,有消息时就会多打几次重复的错误。我看http adapter文档建立连接

...不使用的Session应当被释放,长时间(30分钟)未使用的Session将自动释放,否则Session持续保存Bot收到的消息,将会导致内存泄露(开启websocket后将不会自动释放)

然后websocket adapter建立连接也只提到了

...ws adapter 采用一步认证, 参数通过 header 或者 url 参数传递 在 ws adapter 中, websocket 一经连接, 便绑定到固定 session, 后续不需再次传递 sessionKey 参数 ...ws adatper 不允许复用 sessionKey, 建立连接时, 会同时关闭同一个”通道“中的旧 websocket. 但可以同其他 adapter 复用

看了文档有点糊涂,我的想法是这样的:

所以姑且的结论是websocket的session一定永远不抛弃。请问该如何防止泄漏呢?

mayeths commented 1 year ago

在源码里找了一下websocket轮询的代码

https://github.com/project-mirai/mirai-api-http/blob/6b0a96aa0f9056bf7d210af984fb6f81b1c23492/mirai-api-http/src/main/kotlin/net/mamoe/mirai/api/http/adapter/ws/router/base.kt#L85-L93

看起来只要在轮询某个时候将MahContextHolder[sessionKey]置为空就可以break了,然后找了一下轮询内进行处理的handleWsAction代码

https://github.com/project-mirai/mirai-api-http/blob/e224ec4bd9d8ef3d8fede1298f739266087c3fda/mirai-api-http/src/main/kotlin/net/mamoe/mirai/api/http/adapter/ws/router/action.kt#L137-L142

我没学过kotlin,但想问一下是不是这个also语句没有处理session失效的情况呀,要不要在这加上发现socket关闭就MahContextHolder[sessionKey] = null之类的?

ryoii commented 1 year ago

回答 session 你迷惑的地方

  1. http 创建的 session 因为 http 不是长连接的,所以不会主动释放,需要主动 release
  2. websocket 创建的 session 仅在本次连接有效,websocket 释放后自动释放
  3. websocket 可以复用 http 的 session。相当于一个 session 被引用多次,需要两次释放操作(一次 ws 的自动释放,一次 http 的主动释放)才会真正释放 session
ryoii commented 1 year ago

你贴出代码的部分是处理 imcoming frame 的逻辑,不是本次 bug 出问题的地方。从 bug 的情况来看,出问题的是主动发出 outgoing frame 的时候 socket 已关闭

代码中循环处理 incoming frame 的地方,在 socket 断开后,根据 websocket 的标准,主动断开的一方会发送 close frame。所贴出代码的 for 循环会在手动 close frame 的时候退出。

我猜测可能是不正常的 socket 断开造成的,一方面我还没做异常断开的测试,另一方面目前我的账号无法登录了,测试的问题先往后缓缓

ryoii commented 1 year ago

我执行了你提供的 python 代码,发现并没有执行 close 操作。后续的所有操作都被 online.wait() 阻塞了。

我尝试修改了你的代码, 在接受 3 次消息后关闭 socket。这时,session 被正确释放。

import websocket
import json
import threading

session = None
online = threading.Event()
times = 0

def on_message(ws, message):
    global session
    global times
    print("[message]", message)
    times += 1
    if times > 3:
        ws.close()

def on_error(ws, err):
    print("[error]", err)
    pass

def on_close(ws):
    global session
    print("[closed]")
    online.clear()
    session = None

if __name__ == "__main__":
    online.clear()
    ws = websocket.WebSocketApp("ws://127.0.0.1:9999/test?verifyKey=1111222&qq=384123123123",
                                on_message=on_message,
                                on_error=on_error,
                                on_close=on_close,
                                )
    wst = threading.Thread(target=ws.run_forever)
    wst.daemon = True
    wst.start()

    online.wait()
    print("after wait()")
    txt = json.dumps({
        "syncId": 123,
        "command": "sendFriendMessage",
        "subCommand": None,
        "content": {
            "sessionKey": session,
            "target": "17123123123",
            "messageChain": [
                {"type": "Plain", "text": "haha"},
            ]
        }
    })
    ws.send(txt)

    ws.close()
    print("closed")
    wst.join()
mayeths commented 1 year ago

谢谢,我看文档说这个python的websocket库在多线程环境下共享时需要注意一些细节,可能是要在websocket的线程里close才行,我改一下