merge: W48 —— 聊天页 VM key 带上账号指纹(修换环境后仍显示上个环境的会话)+ 注释纠错 + 把 WS 拦截代理挪进 devtools

This commit is contained in:
AskaEth committed 2026-10-05 22:26:44 +08:00
commit e589ab303d
11 files changed
+308 -21

No files matched your search

@@ -20,7 +20,7 @@ private val Context.sessionDataStore: DataStore<Preferences> by preferencesDataS
*
* @param snapshot 上次成功拉取的会话列表;服务端不可用时用它先渲染
* @param snapshotAt 快照时间(用于「同步于 N 分钟前」)
* @param fingerprint `baseUrl + username`;换服务器/账号后快照作废
* @param fingerprint [accountFingerprint]:换服务器/账号后快照作废
* @param activeSessionId 上次停留的会话
*/
data class SessionPrefs(
@@ -35,6 +35,20 @@ data class SessionPrefs(
snapshot.takeIf { this.fingerprint == fingerprint }.orEmpty()
}
/**
* 账号指纹(纯函数):`baseUrl + username`。回答的是「**这份本地状态属于哪个环境**」。
*
* 抽成一个函数的理由是**全仓只能有一份口径**:这类判据一旦各拼一套字符串,差一个分隔符
* 就会在某个闸门上漏过去——而漏过去的后果是把别的服务器/账号的内容摆到屏幕上。
* 现在的两个消费者是 [SessionPrefs.snapshotFor](会话快照作不作废)与聊天页的 VM key
* (`chatViewModelKey`:那个 VM 是不是本环境的)。改口径就是改**两者**,不要就地另拼。
*
* 取用户名而不是角色:角色是同一个人在服务端被授的权限,账号身份由**服务器地址 + 用户名**
* 共同确定;要让指纹对不上,只能是换了服务器或换了账号,那两件事都该让本地状态作废。
*/
internal fun accountFingerprint(baseUrl: String, username: String?): String =
"$baseUrl|${username.orEmpty()}"
class SessionPreferences(context: Context) {
private val json = ApiClientFactory.json
@@ -27,8 +27,11 @@ private const val SESSION_GONE_NOTICE_MILLIS = 8_000L
* 会话在别处被删掉时那句如实告知。
*
* 它必须由**这一层**持有,不能挂在 `ChatViewModel` 上:`onSessionGone` 一被调用,
* `activeSessionId` 就会被收敛回主会话,那个会话的 VM 连同它的状态**立刻被销毁**,
* 挂在上面的横幅一个字都来不及显示(真机实测过)。这一层的 `remember` 活得比它久。
* `activeSessionId` 就会被收敛回主会话,ChatScreen 的 VM key(含会话 id)随之变化 ——
* 变的是 key,于是**承载横幅的那段组合被销毁重建**;那个 VM 实例本身住在 Activity 级的
* ViewModelStore 里,**仍然活着、继续收帧**(连登出都不回收它,见 `chatViewModelKey` 的 KDoc)。
* 横幅挂在 VM 状态上的话活不过这一帧、一个字都来不及显示(真机实测过)。
* 这一层的 `remember` 活得比它久。
*/
private const val SESSION_GONE_MESSAGE = "这个会话在服务端已经不存在了(可能已在别处删除),已切回主会话"
@@ -52,7 +55,7 @@ fun ChatHomeScreen(
var showAbout by remember { mutableStateOf(false) }
// 当前会话在别处被删掉时那句告知(见 [SESSION_GONE_MESSAGE])。放在这一层是因为承载它的
// 会话 VM 会在切回主会话的瞬间被销毁。
// 那段组合(ChatScreen)会在切回主会话的瞬间被销毁重建,而这里的 remember 活得比它久。
var sessionNotice by remember { mutableStateOf<String?>(null) }
LaunchedEffect(sessionNotice) {
@@ -104,8 +107,8 @@ fun ChatHomeScreen(
// `refresh` 里面会把「指向已删会话」的 activeSessionId 收敛回主会话
// (`resolveActiveSession`),抽屉里那条也跟着消失。所以这里不需要自己判「该切到哪儿」。
//
// 顺序很重要:告知必须**先**立起来再刷新——刷新会让本层的 ChatScreen 换 key、
// 把那个会话的 VM 销毁掉,晚一步就什么都看不到了。
// 顺序很重要:告知必须**先**立起来再刷新——刷新会让本层的 ChatScreen 换 key,
// 那段组合连同挂在 VM 状态上的东西一起被丢弃重建,晚一步就什么都看不到了。
onSessionGone = {
sessionNotice = SESSION_GONE_MESSAGE
sessionsViewModel.refresh(silent = true)
@@ -58,6 +58,7 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import top.yeij.cyrene.app.AppContainer
import top.yeij.cyrene.app.ShareIntake
import top.yeij.cyrene.core.prefs.accountFingerprint
import top.yeij.cyrene.data.repository.DocumentCacheStore
import top.yeij.cyrene.data.repository.ImageCacheStore
import top.yeij.cyrene.data.ws.WsState
@@ -108,6 +109,21 @@ private const val SEARCH_NOTICE_MILLIS = 8_000L
/** 输入栏下方预留的高度:避开屏幕圆角,同时承载忙碌提示。 */
private val InputBottomSpacer = 22.dp
/**
* 聊天页那个 [ChatViewModel] 的 key(纯函数):**账号指纹 + 会话 id**。
*
* `viewModel(key = ...)` 的 owner 是 **Activity 级的 ViewModelStore**,而登出只把本组合移出、
* **不清理那个 store**([ChatViewModel.onCleared] 是空的,全仓也没有 `ViewModelStore.clear()`)。
* key 里只放 sessionId 的话,换账号/换服务器再进来时,`viewModel()` 会把**上一个环境**
* 那个还活着的实例原样取回来——它会继续收那个环境的帧,屏幕上摆的就是别的服务器的消息。
* 指纹进 key 之后,环境一变就取到新实例;同一个环境里切会话仍然各留一份状态(key 未变)。
*
* [accountFingerprint] 与 [SessionPreferences] / `SessionsViewModel` 是**同一份**口径:
* 别在这里另拼一套字符串——两套判据早晚会分叉,而分叉的那一侧就是漏出去的那一侧。
*/
internal fun chatViewModelKey(accountFingerprint: String, sessionId: String): String =
"$accountFingerprint|$sessionId"
/**
* 选文件时交给系统选择器的 MIME 列表。
*
@@ -147,8 +163,16 @@ fun ChatScreen(
onSessionGone: () -> Unit = {},
sessionNotice: String? = null,
) {
// 账号指纹:**必须进下面那两个 VM 的 key**。登出只是把本组合移出(见 CyreneApp 的 when),
// 而这两个 VM 住在 Activity 级的 ViewModelStore 里、**不会随登出回收**——key 里不带指纹的话,
// 换账号/换服务器再进来会按同一个 key 取回上一个环境那个还活着的实例,屏幕上摆的就是
// 别的服务器的消息。`remember` 每次进入本组合重算一次(换环境必经登出 ⇒ 本组合被销毁重建),
// 所以不会拿着上一份指纹;不在每次重组时读加密存储是因为流式期间重组非常频繁。
val account = remember {
accountFingerprint(container.authManager.currentBaseUrl, container.sessionStore.load().username)
}
val viewModel: ChatViewModel = viewModel(
key = sessionId,
key = chatViewModelKey(account, sessionId),
factory = viewModelFactory { initializer { ChatViewModel(container, sessionId) } },
)
val state by viewModel.state.collectAsState()
@@ -159,9 +183,10 @@ fun ChatScreen(
// 所以这里不会出现「已经主会话还往上跳一次」的回环。
//
// ⚠️ **那句解释不能挂在这个 VM 上**:`onSessionGone` 一被调用,上层就会把 activeSessionId 收敛回
// 主会话,`key = sessionId` 随之变化 ⇒ **这个 VM 连同它的状态一起被销毁**,挂在上面的横幅
// 一个字都来不及显示(真机实测过:切回主会话干净利落,用户完全不知道为什么)。
// 所以文案由 [sessionNotice] 从上层注入(那个状态活得比本 VM 久)。
// 主会话,VM key 里的会话 id 随之变化 ⇒ **承载它的那段组合被销毁重建**。(变的是 key,
// 不是 VM:那个实例仍在 Activity 级的 ViewModelStore 里活着、继续收帧——见上面 key 的 KDoc。)
// 挂在 VM 状态上的横幅活不过这一帧,一个字都来不及显示(真机实测过:切回主会话干净利落,
// 用户完全不知道为什么)。所以文案由 [sessionNotice] 从上层注入(那个状态活得比本 VM 久)。
LaunchedEffect(state.sessionGone) {
if (state.sessionGone) onSessionGone()
}
@@ -175,7 +200,9 @@ fun ChatScreen(
// 待执行的定位请求。命中在窗口里时先关面板再滚,交给这个 effect 在组合完成之后做。
var pendingScroll by remember { mutableStateOf<Int?>(null) }
val searchViewModel: SessionSearchViewModel = viewModel(
key = "session-search:$sessionId",
// 与上面那个 VM 同样的理由:搜索命中也是**上一个环境的聊天内容**,key 里不带账号指纹的话,
// 换环境后在主会话(id 恒为 web)上重开搜索,面板里就是别的服务器的消息。
key = "session-search:${chatViewModelKey(account, sessionId)}",
factory = viewModelFactory {
initializer { SessionSearchViewModel(container, sessionId) }
},
@@ -959,8 +959,8 @@ class ChatViewModel(
// 连接是**进程内共享**的那一条(`AppContainer.chatSocket`),本 VM 只订阅它。开与关归
// 「登录态」那条路径(唯一主人是 `SessionsViewModel`,见它的类注释):`connect()` 虽然幂等,
// 但主人只有一个——本 VM 会随「切到别的会话」被销毁,让它插手就等于让一个屏幕的生死
// 去掐全 App 的连接。
// 但主人只有一个——本 VM 的 key 一换(切会话 / 换账号)就只是另起一个实例,旧实例仍留在
// Activity 级的 `ViewModelStore` 里,让它插手就等于让一个屏幕的生死去掐全 App 的连接。
val ws = container.chatSocket
socket = ws
viewModelScope.launch { ws.state.collect(::onConnectionChanged) }
@@ -2905,9 +2905,11 @@ class ChatViewModel(
fun dismissError() = _state.update { it.copy(error = null) }
/**
* **刻意不关连接**:`socket` 是**全 App 共享**的那一条(`AppContainer.chatSocket`),而本 VM 随
* 「切到别的会话」就会被销毁。在 `onCleared` 里 close 会连抽屉的未读、管理页的三帧、别的会话的
* 流式一起掐断——连接的开与关只由**登录态**表达(主人是 `SessionsViewModel`,见它的类注释)。
* **刻意不关连接**:`socket` 是**全 App 共享**的那一条(`AppContainer.chatSocket`),而本 VM
* 并不随切会话 / 登出消失——它的 key 一换只是另起一个实例,旧实例仍留在 Activity 级的
* `ViewModelStore` 里(见 `chatViewModelKey` 的 KDoc),`onCleared` 要等整个 Activity 结束才来。
* 在 `onCleared` 里 close 会连抽屉的未读、管理页的三帧、别的会话的流式一起掐断——连接的
* 开与关只由**登录态**表达(主人是 `SessionsViewModel`,见它的类注释)。
*/
override fun onCleared() {
super.onCleared()
@@ -122,7 +122,7 @@ enum class RestFailureLanding {
*
* ## 为什么是「直接试 REST」(W45 主人的定案)
* W41 的阶梯是「WS 未就绪 ⇒ **先探 `GET /api/v1/health`** ⇒ 通才回落 REST」。真机回归
* (`devtools/ws_block_proxy.py` 只掐 `/ws/chat`、HTTP 全放行)发现消息**进了队列而不是回落
* (`devtools/ws-block-proxy/ws_block_proxy.py` 只掐 `/ws/chat`、HTTP 全放行)发现消息**进了队列而不是回落
* REST**;而 `ProbeClientTest` 证明那条链在 JVM 上是通的,`Unreachable` 生在**连接之前**,三种
* 成因(建 client 抛 / 连上但一个字没写 / ConnectException)**在外同形** ⇒ 那一轮无法定论。
*
@@ -48,8 +48,10 @@ data class SessionSearchUiState(
* 会话内搜索的小 ViewModel:**只属于聊天页的搜索态**,与 [ChatViewModel] 完全不相干——
* 搜索不改变消息窗、不碰 WS、不发消息,所以它不该住进那个正在被另一个模块改动的 VM 里。
*
* 作用域由调用方决定(`viewModel(key = "session-search:<id>")`),因此每个会话各有一份,
* 切回来时上一次的查询与结果还在。取数一律走既有的 `MessageRepository`。
* 作用域由调用方决定(`viewModel(key = "session-search:<账号指纹>|<id>")`),因此每个会话各有一份、
* 切回来时上一次的查询与结果还在;而**换账号/换服务器后指纹变了 ⇒ 取到的是新实例**,
* 面板里不会残留上一个环境的命中(那些命中就是别的服务器的消息正文)。
* 取数一律走既有的 `MessageRepository`。
*
* 并发:`generation` 是「谁的结果才算数」的代际号。搜索是**用户连打两次回车**就会并发的地方,
* 加上翻页与首批可以同时存在,没有代际号就会出现「先发的慢请求后到,把新关键词的结果顶掉」。
@@ -10,6 +10,7 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import top.yeij.cyrene.app.AppContainer
import top.yeij.cyrene.core.auth.SessionState
import top.yeij.cyrene.core.prefs.accountFingerprint
import top.yeij.cyrene.data.api.CyreneException
import top.yeij.cyrene.data.api.dto.SessionSummaryDto
import top.yeij.cyrene.data.ws.ChatSocket
@@ -394,8 +395,9 @@ class SessionsViewModel(private val container: AppContainer) : ViewModel() {
readCursors[sessionId] = maxOf(readCursors[sessionId] ?: 0L, readAt)
}
/** 与聊天页的 VM key 用**同一份**口径([accountFingerprint]),不要就地另拼字符串。 */
private fun fingerprint(): String =
"${container.authManager.currentBaseUrl}|${container.sessionStore.load().username.orEmpty()}"
accountFingerprint(container.authManager.currentBaseUrl, container.sessionStore.load().username)
}
internal fun SessionSummaryDto.toItem(): SessionItem = SessionItem(
@@ -0,0 +1,75 @@
package top.yeij.cyrene.ui.chat
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotEquals
import org.junit.Test
import top.yeij.cyrene.core.prefs.accountFingerprint
/**
* 聊天页那两个 VM 的 key 里**必须带账号指纹**。
*
* 要钉住的事:这个 VM 的 owner 是 Activity 级的 `ViewModelStore`,登出只是把 ChatScreen
* 移出组合、**不回收 store 里的实例**(全仓没有 `ViewModelStore.clear()`)。所以只要 key 不变,
* `viewModel()` 就会把**上一个环境**那个还活着的实例原样取回来——它继续收着上个服务器的帧,
* 屏幕上摆的就是别的环境的消息。判据因此很直接:**环境一变,key 必须变**;环境没变,
* key 必须不变(否则同一个环境里切一下会话 / 重组几次就把状态丢了)。
*
* 纯粹的 seam 测试:`viewModel()` 与 Android 的 store 拖不进 JVM 测试,这里只钉拼法。
*/
class ChatViewModelKeyTest {
private val hostA = "http://host-a:10942/"
private val hostB = "http://host-b:10942/"
@Test
fun `same account on the same server yields the same key`() {
val first = chatViewModelKey(accountFingerprint(hostA, "testuser"), "web")
val again = chatViewModelKey(accountFingerprint(hostA, "testuser"), "web")
assertEquals(first, again)
}
@Test
fun `another server yields another key`() {
val here = chatViewModelKey(accountFingerprint(hostA, "testuser"), "web")
assertNotEquals(here, chatViewModelKey(accountFingerprint(hostB, "testuser"), "web"))
}
@Test
fun `another account on the same server yields another key`() {
val mine = chatViewModelKey(accountFingerprint(hostA, "testuser"), "web")
assertNotEquals(mine, chatViewModelKey(accountFingerprint(hostA, "someone-else"), "web"))
}
@Test
fun `another session inside the same account yields another key`() {
val main = chatViewModelKey(accountFingerprint(hostA, "testuser"), "web")
// 同一个环境里每个会话各留一份状态:key 必须分得开,否则切会话会把上一个会话的消息留下来。
assertNotEquals(main, chatViewModelKey(accountFingerprint(hostA, "testuser"), "web:sub-1"))
}
@Test
fun `a missing username still cannot collide with a blank one on another server`() {
// 登录状态里 username 缺失(老安装 / 异常路径)时指纹退化成 baseUrl 一段:
// 地址不同仍然分得开,这才是这条缺陷最要紧的一半。
assertNotEquals(
chatViewModelKey(accountFingerprint(hostA, null), "web"),
chatViewModelKey(accountFingerprint(hostB, null), "web"),
)
// 缺失与空串是同一个意思,不该拼出两种 key(否则同一次登录的两个时刻会各是各的)。
assertEquals(
accountFingerprint(hostA, null),
accountFingerprint(hostA, ""),
)
}
@Test
fun `the account fingerprint is baseUrl plus username, the existing caliber`() {
// 口径与 SessionPreferences / SessionsViewModel 同一份:就地改这里会同时改掉两处闸门。
assertEquals("http://host-a:10942/|testuser", accountFingerprint(hostA, "testuser"))
assertEquals("http://host-a:10942/|", accountFingerprint(hostA, null))
}
}
@@ -19,7 +19,7 @@ import top.yeij.cyrene.domain.chat.SendChannel
*
* ## 背景:W45 把探测换成了直接尝试(主人的定案)
* W41 的阶梯是「WS 未就绪 ⇒ 先探 `GET /api/v1/health` ⇒ 通才回落 REST」。真机回归
* (`devtools/ws_block_proxy.py` 只掐 `/ws/chat`、HTTP 全放行)发现消息进了队列而不是回落 REST,
* (`devtools/ws-block-proxy/ws_block_proxy.py` 只掐 `/ws/chat`、HTTP 全放行)发现消息进了队列而不是回落 REST,
* 而 `ProbeClientTest` 证明那条链在 JVM 上是通的、`Unreachable` 生在**连接之前**,三种成因在外
* 同形 ⇒ 无法定论。判据与论证见 [sendRouteFor]。
*
+72
View File
@@ -0,0 +1,72 @@
# WS 断、HTTP 通的窄代理(`devtools/ws-block-proxy/`)
一个**只掐 `/ws/chat`、其余 HTTP 一律放行**的窄代理:用来把「WebSocket 断了,但 REST 还通」
这个场景变成可复现、可取证的状态。纯 Python 3 标准库,零第三方依赖。
它**不是**产品的一部分,也**不是**后端行为的权威来源。它只做一件事:把发往 `/ws/chat`
的请求就地回 `503`(`ws blocked by test proxy`),别的请求原样转发给真后端,并把
**每一行的去向**打到 stdout。
它**不做什么**:不鉴权、不改写请求体、不落盘、不重试、不实现任何业务端点。
转发时逐行丢掉 hop-by-hop 头(`Connection` / `Upgrade` / `Transfer-Encoding` 等),
请求体按 `Content-Length` 读全再转发——**只要真后端还听得懂,它就照转**。
---
## 1. 用途
W41 的「三级阶梯」(WS → 健康探测 → REST 回落)只在**WS 连不上**时才会走。真实的断线
不可控也不好复现,用这个代理把 WS 单独掐掉即可:客户端应该表现为「重连中…」(而不是「离线」),
然后按阶梯回落 `POST /api/v1/chat` 把消息发出去。
排查时的用处同样直接:**想知道 App 到底发了什么、发没发出去**,看代理日志就行。
## 2. 用法
```bash
python ws_block_proxy.py <listen_port> <target_port>
```
- `<listen_port>`:代理自己监听的端口(**App 要连的那个口**,见下面的 `adb reverse`)
- `<target_port>`:真后端在本机监听的端口
典型用法(真后端在 10942,代理听 18098):
```bash
# 终端 A:代理听 18098,转发给 127.0.0.1:10942
cd /d/Project/Code/Uni/Cyrene-For-Android/devtools/ws-block-proxy
python ws_block_proxy.py 18098 10942
# 终端 B:把设备上的 10942 引到宿主机的 18098(即代理)
adb reverse tcp:10942 tcp:18098
```
App 侧不用改任何配置:它照旧连 `127.0.0.1:10942`,被 `adb reverse` 送到代理。
日志(stdout)长这样:
```
proxy 18098 -> 127.0.0.1:10942, blocking /ws/chat # 启动
BLOCKED GET /ws/chat?token=... # WS 被掐(App 会退避重试)
PASS GET /api/v1/sessions/web/messages?limit=50 -> 200
PASS POST /api/v1/chat -> 200
ERR GET /api/v1/health -> ConnectionRefusedError(...) # 真后端没起来时
```
收尾:撤掉端口映射(`adb reverse --remove tcp:10942`)、Ctrl-C 停代理。
**用完记得撤**——留着的话别的会话连真后端也会经过这个「掐 WS」的口。
## 3. 关键性质(它最有价值的一条)
**调试期间 Web 前端是直连后端(`127.0.0.1:10942`)的,不经过 `adb reverse`。**
所以设备侧的口只有 App 会用,**代理日志里的每一行都只可能来自 App** ——
不存在「这条请求到底是谁发的」这个问题。这是分辨「App 到底发了什么」最干净的判据:
要确认某次发送走的是 WS 还是 REST 回落,看日志里有没有对应的 `PASS POST /api/v1/chat` 就够了,
不需要在客户端加任何日志。
## 4. 注意
- **日志里会带查询串**(例如 `/ws/chat?token=...`),贴给别人之前先抹掉凭据。
脚本本身**不硬编码任何凭据**,也不写任何文件。
- 它**不是**后端行为的事实来源:要判断真后端怎么回,去看真后端本身。
- 端口随便挑,但**别让代理和真后端抢同一个口**;`10942` 是真后端的常规端口。
+90
View File
@@ -0,0 +1,90 @@
"""W41 回归用的窄代理:HTTP 全放行,只把 /ws/chat 掐掉。
用途:模拟「WS 断了但 HTTP 通」——W41 三级阶梯应该在这种情况下
探测 /api/v1/health 成功、回落 POST /api/v1/chat 把消息发出去。
仅本机开发用;这个文件**在 git 里**(`devtools/ws-block-proxy/`),不再放 `app/build/`——
那里被 gitignore,一次 `gradlew clean` 就把它扫没了。用法见同目录的 README.md。
用法:python ws_block_proxy.py <listen_port> <target_port>
"""
import http.client
import socketserver
import sys
LISTEN = int(sys.argv[1])
TARGET = int(sys.argv[2])
BLOCK = "/ws/chat"
HOP_BY_HOP = {
"connection", "keep-alive", "proxy-authenticate", "proxy-authorization",
"te", "trailers", "transfer-encoding", "upgrade",
}
class Handler(socketserver.BaseRequestHandler):
def handle(self):
self.request.settimeout(30)
buf = b""
while b"\r\n\r\n" not in buf:
chunk = self.request.recv(4096)
if not chunk:
return
buf += chunk
head, _, rest = buf.partition(b"\r\n\r\n")
lines = head.decode("latin-1").split("\r\n")
method, path, _ = lines[0].split(" ", 2)
if path.split("?")[0] == BLOCK:
body = b"ws blocked by test proxy"
self.request.sendall(
b"HTTP/1.1 503 Service Unavailable\r\n"
b"Content-Type: text/plain\r\n"
b"Content-Length: " + str(len(body)).encode() + b"\r\n\r\n" + body
)
print("BLOCKED %s %s" % (method, path), flush=True)
return
headers = {}
for line in lines[1:]:
k, _, v = line.partition(":")
if k.lower() in HOP_BY_HOP:
continue
headers[k.strip()] = v.strip()
length = int(headers.get("Content-Length") or 0)
body = rest
while len(body) < length:
more = self.request.recv(length - len(body))
if not more:
break
body += more
conn = http.client.HTTPConnection("127.0.0.1", TARGET, timeout=60)
try:
conn.request(method, path, body=body if length else None, headers=headers)
resp = conn.getresponse()
payload = resp.read()
out = ["HTTP/1.1 %d %s" % (resp.status, resp.reason)]
for k, v in resp.getheaders():
if k.lower() in HOP_BY_HOP or k.lower() == "content-length":
continue
out.append("%s: %s" % (k, v))
out.append("Content-Length: %d" % len(payload))
self.request.sendall(("\r\n".join(out) + "\r\n\r\n").encode("latin-1") + payload)
print("PASS %s %s -> %d" % (method, path, resp.status), flush=True)
except Exception as exc: # noqa: BLE001
self.request.sendall(b"HTTP/1.1 502 Bad Gateway\r\nContent-Length: 0\r\n\r\n")
print("ERR %s %s -> %r" % (method, path, exc), flush=True)
finally:
conn.close()
class Server(socketserver.ThreadingTCPServer):
allow_reuse_address = True
daemon_threads = True
if __name__ == "__main__":
print("proxy %d -> 127.0.0.1:%d, blocking %s" % (LISTEN, TARGET, BLOCK), flush=True)
Server(("127.0.0.1", LISTEN), Handler).serve_forever()