原来交上去 5 秒没结果就每 2 秒(流程图 3 秒)拉一次,分不清 WebSocket 断了还是队列长: 50 人一起交、队列排一分钟时每秒二十几个请求,全在和判题抢那两个核。 - 收到这一条的任何推送(服务端订阅时就回一帧当前状态)就认定 WebSocket 是通的, 轮询退到 20 秒一次,只防推送丢失;连接断了才回快档,2 秒起按 1.5 倍退到 10 秒 - 拉失败不再永久停掉(掉线那几秒拉失败是常事),连着失败 5 次才放弃 - 页面在后台时不拉;比赛题目页、榜单、班级页的定时刷新也一样,切回来立刻补一次 - 代码判题和流程图评分共用 shared/composables/fallbackPoll.ts Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
593a409fca
commit
64994b9499
7 files changed
+199
-105
No files matched your search
@@ -11,6 +11,7 @@ import { useLeaveStudents } from "shared/composables/leaveStudents"
|
|||||||
import { ContestStatus, LANGUAGE_FORMAT_VALUE } from "utils/constants"
|
import { ContestStatus, LANGUAGE_FORMAT_VALUE } from "utils/constants"
|
||||||
import { parseTime, secondsToDuration } from "utils/functions"
|
import { parseTime, secondsToDuration } from "utils/functions"
|
||||||
import type { Submission } from "utils/types"
|
import type { Submission } from "utils/types"
|
||||||
|
import { useVisibleInterval } from "shared/composables/visibleInterval"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 老师的「全班情况」,考完叫「成绩」(设计稿「比赛重设计」老师两块)。学生看不到这一页。
|
* 老师的「全班情况」,考完叫「成绩」(设计稿「比赛重设计」老师两块)。学生看不到这一页。
|
||||||
@@ -43,7 +44,7 @@ async function load() {
|
|||||||
const running = computed(() => contestStore.contestStatus === ContestStatus.underway)
|
const running = computed(() => contestStore.contestStatus === ContestStatus.underway)
|
||||||
const ended = computed(() => contestStore.contestStatus === ContestStatus.finished)
|
const ended = computed(() => contestStore.contestStatus === ContestStatus.finished)
|
||||||
|
|
||||||
const { pause, resume } = useIntervalFn(load, 15_000, { immediate: false })
|
const { pause, resume } = useVisibleInterval(load, 15_000)
|
||||||
watch(
|
watch(
|
||||||
() => [contestStore.canEnter, running.value] as const,
|
() => [contestStore.canEnter, running.value] as const,
|
||||||
([enter, run]) => {
|
([enter, run]) => {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { useUserStore } from "shared/store/user"
|
|||||||
import { ContestStatus } from "utils/constants"
|
import { ContestStatus } from "utils/constants"
|
||||||
import { parseTime, secondsToDuration } from "utils/functions"
|
import { parseTime, secondsToDuration } from "utils/functions"
|
||||||
import RankMini from "../components/RankMini.vue"
|
import RankMini from "../components/RankMini.vue"
|
||||||
|
import { useVisibleInterval } from "shared/composables/visibleInterval"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 比赛页「题目」(设计稿「比赛重设计」B,用户选的):一页放下。
|
* 比赛页「题目」(设计稿「比赛重设计」B,用户选的):一页放下。
|
||||||
@@ -24,12 +25,9 @@ const tone = useTone()
|
|||||||
const running = computed(() => contestStore.contestStatus === ContestStatus.underway)
|
const running = computed(() => contestStore.contestStatus === ContestStatus.underway)
|
||||||
const ended = computed(() => contestStore.contestStatus === ContestStatus.finished)
|
const ended = computed(() => contestStore.contestStatus === ContestStatus.finished)
|
||||||
|
|
||||||
const { pause, resume } = useIntervalFn(
|
const { pause, resume } = useVisibleInterval(
|
||||||
() => contestStore.loadScoreboard(props.contestID),
|
() => contestStore.loadScoreboard(props.contestID),
|
||||||
10_000,
|
10_000,
|
||||||
{
|
|
||||||
immediate: false,
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
watch(
|
watch(
|
||||||
() => [contestStore.canEnter, contestStore.contestStatus] as const,
|
() => [contestStore.canEnter, contestStore.contestStatus] as const,
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { ContestStatus } from "utils/constants"
|
|||||||
import { secondsToDuration } from "utils/functions"
|
import { secondsToDuration } from "utils/functions"
|
||||||
import { classLabel } from "oj/submission/utils"
|
import { classLabel } from "oj/submission/utils"
|
||||||
import UserName from "shared/components/UserName.vue"
|
import UserName from "shared/components/UserName.vue"
|
||||||
|
import { useVisibleInterval } from "shared/composables/visibleInterval"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 比赛排名(设计稿「比赛重设计」排行榜两块)。竞争感是用户要的(「我需要竞争」):
|
* 比赛排名(设计稿「比赛重设计」排行榜两块)。竞争感是用户要的(「我需要竞争」):
|
||||||
@@ -26,12 +27,9 @@ const running = computed(() => contestStore.contestStatus === ContestStatus.unde
|
|||||||
const ended = computed(() => contestStore.contestStatus === ContestStatus.finished)
|
const ended = computed(() => contestStore.contestStatus === ContestStatus.finished)
|
||||||
|
|
||||||
const autoRefresh = ref(true)
|
const autoRefresh = ref(true)
|
||||||
const { pause, resume } = useIntervalFn(
|
const { pause, resume } = useVisibleInterval(
|
||||||
() => contestStore.loadScoreboard(props.contestID),
|
() => contestStore.loadScoreboard(props.contestID),
|
||||||
10_000,
|
10_000,
|
||||||
{
|
|
||||||
immediate: false,
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
watch(
|
watch(
|
||||||
() => [contestStore.canEnter, running.value, autoRefresh.value] as const,
|
() => [contestStore.canEnter, running.value, autoRefresh.value] as const,
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { ref, computed, getCurrentScope, onScopeDispose } from "vue"
|
import { ref, computed } from "vue"
|
||||||
import { useIntervalFn, useTimeoutFn } from "@vueuse/core"
|
|
||||||
import { getSubmission } from "oj/api"
|
import { getSubmission } from "oj/api"
|
||||||
import { SubmissionStatus } from "utils/constants"
|
import { SubmissionStatus } from "utils/constants"
|
||||||
import type { PendingAchievement, Submission } from "utils/types"
|
import type { PendingAchievement, Submission } from "utils/types"
|
||||||
import { useAchievementStore } from "shared/store/achievement"
|
import { useAchievementStore } from "shared/store/achievement"
|
||||||
import { useSubmissionWebSocket, type SubmissionUpdate } from "shared/composables/websocket"
|
import { useSubmissionWebSocket, type SubmissionUpdate } from "shared/composables/websocket"
|
||||||
|
import { useFallbackPoll } from "shared/composables/fallbackPoll"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 判题监控 Composable
|
* 判题监控 Composable
|
||||||
@@ -15,33 +15,6 @@ export function useSubmissionMonitor() {
|
|||||||
const submissionId = ref("")
|
const submissionId = ref("")
|
||||||
const submission = ref<Submission>()
|
const submission = ref<Submission>()
|
||||||
|
|
||||||
// ==================== 轮询机制 ====================
|
|
||||||
const { pause: pausePolling, resume: resumePolling } = useIntervalFn(
|
|
||||||
async () => {
|
|
||||||
const id = submissionId.value
|
|
||||||
if (!id) return
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await getSubmission(id)
|
|
||||||
// 请求在路上时换了题(reset)或者又交了一次:回来的是上一条,扔掉。
|
|
||||||
// 不扔的话上一道题判对了,会把新题标成已解决、弹点评
|
|
||||||
if (id !== submissionId.value) return
|
|
||||||
submission.value = res
|
|
||||||
|
|
||||||
const result = res.result
|
|
||||||
// 判题完成,停止轮询
|
|
||||||
if (result !== SubmissionStatus.judging && result !== SubmissionStatus.pending) {
|
|
||||||
pausePolling()
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("[SubmissionMonitor] 轮询失败:", error)
|
|
||||||
pausePolling()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
2000,
|
|
||||||
{ immediate: false },
|
|
||||||
)
|
|
||||||
|
|
||||||
// ==================== WebSocket 处理 ====================
|
// ==================== WebSocket 处理 ====================
|
||||||
const handleSubmissionUpdate = (data: SubmissionUpdate) => {
|
const handleSubmissionUpdate = (data: SubmissionUpdate) => {
|
||||||
// push_to_user 复用了 submission_update 这个 channel handler,
|
// push_to_user 复用了 submission_update 这个 channel handler,
|
||||||
@@ -64,6 +37,8 @@ export function useSubmissionMonitor() {
|
|||||||
console.log("[SubmissionMonitor] 提交ID不匹配,忽略")
|
console.log("[SubmissionMonitor] 提交ID不匹配,忽略")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// 订阅后服务端立刻回的那一帧(还在排队也回)就算:WebSocket 是通的,轮询退到慢档
|
||||||
|
fallback.confirm()
|
||||||
|
|
||||||
if (!submission.value) {
|
if (!submission.value) {
|
||||||
submission.value = {} as Submission
|
submission.value = {} as Submission
|
||||||
@@ -76,7 +51,7 @@ export function useSubmissionMonitor() {
|
|||||||
console.log(`[SubmissionMonitor] 判题${data.status === "finished" ? "完成" : "出错"}`)
|
console.log(`[SubmissionMonitor] 判题${data.status === "finished" ? "完成" : "出错"}`)
|
||||||
|
|
||||||
// 停止轮询(WebSocket已成功)
|
// 停止轮询(WebSocket已成功)
|
||||||
pausePolling()
|
fallback.stop()
|
||||||
// 结果已经到手,别让重连再去重放这条早就判完的订阅
|
// 结果已经到手,别让重连再去重放这条早就判完的订阅
|
||||||
unsubscribe()
|
unsubscribe()
|
||||||
|
|
||||||
@@ -91,25 +66,23 @@ export function useSubmissionMonitor() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 初始化 WebSocket
|
// 初始化 WebSocket
|
||||||
const { connect, subscribe, unsubscribe, scheduleDisconnect, cancelScheduledDisconnect } =
|
const { connect, subscribe, unsubscribe, scheduleDisconnect, cancelScheduledDisconnect, status } =
|
||||||
useSubmissionWebSocket(handleSubmissionUpdate)
|
useSubmissionWebSocket(handleSubmissionUpdate)
|
||||||
|
|
||||||
// ==================== 轮询保底启动 ====================
|
// ==================== 轮询兜底 ====================
|
||||||
const { start: startPollingFallback } = useTimeoutFn(
|
const fallback = useFallbackPoll({
|
||||||
() => {
|
status,
|
||||||
if (
|
poll: async () => {
|
||||||
submission.value &&
|
const id = submissionId.value
|
||||||
(submission.value.result === SubmissionStatus.judging ||
|
if (!id) return true
|
||||||
submission.value.result === SubmissionStatus.pending ||
|
const res = await getSubmission(id)
|
||||||
submission.value.result === SubmissionStatus.submitting)
|
// 请求在路上时换了题(reset)或者又交了一次:回来的是上一条,扔掉。
|
||||||
) {
|
// 不扔的话上一道题判对了,会把新题标成已解决、弹点评
|
||||||
console.log("[SubmissionMonitor] WebSocket未及时响应,启动轮询保底")
|
if (id !== submissionId.value) return false
|
||||||
resumePolling()
|
submission.value = res
|
||||||
}
|
return res.result !== SubmissionStatus.judging && res.result !== SubmissionStatus.pending
|
||||||
},
|
},
|
||||||
5000,
|
})
|
||||||
{ immediate: false },
|
|
||||||
)
|
|
||||||
|
|
||||||
// ==================== 启动监控 ====================
|
// ==================== 启动监控 ====================
|
||||||
const startMonitoring = (id: string) => {
|
const startMonitoring = (id: string) => {
|
||||||
@@ -131,8 +104,8 @@ export function useSubmissionMonitor() {
|
|||||||
// 把各自那个早就判完的旧 submissionId 重新订阅一遍。
|
// 把各自那个早就判完的旧 submissionId 重新订阅一遍。
|
||||||
subscribe(id)
|
subscribe(id)
|
||||||
|
|
||||||
// 5秒后启动轮询保底(防止WebSocket失败)
|
// 轮询兜底:WebSocket 通的话 20 秒才问一次,不通才快拉(见 fallbackPoll.ts)
|
||||||
startPollingFallback()
|
fallback.start()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -141,7 +114,7 @@ export function useSubmissionMonitor() {
|
|||||||
* 判完触发的「通过」处理会把**新题**标成已解决。提交本身照常判完落库,只是这边不再跟。
|
* 判完触发的「通过」处理会把**新题**标成已解决。提交本身照常判完落库,只是这边不再跟。
|
||||||
*/
|
*/
|
||||||
const reset = () => {
|
const reset = () => {
|
||||||
pausePolling()
|
fallback.stop()
|
||||||
unsubscribe()
|
unsubscribe()
|
||||||
// 连接由 store 持有,离开题目页组件卸了它也还在。和判完之后一样,空闲 15 分钟断开;
|
// 连接由 store 持有,离开题目页组件卸了它也还在。和判完之后一样,空闲 15 分钟断开;
|
||||||
// 不在这里立刻断,是因为刚交的那条判完之后还可能推成就过来
|
// 不在这里立刻断,是因为刚交的那条判完之后还可能推成就过来
|
||||||
@@ -161,11 +134,6 @@ export function useSubmissionMonitor() {
|
|||||||
return judging.value || pending.value || submitting.value
|
return judging.value || pending.value || submitting.value
|
||||||
})
|
})
|
||||||
|
|
||||||
// ==================== 清理 ====================
|
|
||||||
// 现在由 submission store 持有(见 oj/store/submission.ts),store 不销毁就一直在;
|
|
||||||
// 用 onScopeDispose 是为了在组件里调用时照样能收尾
|
|
||||||
if (getCurrentScope()) onScopeDispose(pausePolling)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
// 状态
|
// 状态
|
||||||
submissionId,
|
submissionId,
|
||||||
@@ -179,7 +147,6 @@ export function useSubmissionMonitor() {
|
|||||||
|
|
||||||
// 方法
|
// 方法
|
||||||
startMonitoring,
|
startMonitoring,
|
||||||
pausePolling,
|
|
||||||
reset,
|
reset,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -14,6 +14,7 @@ import { getFlowchartScores, getFlowchartSubmission, submitFlowchart } from "oj/
|
|||||||
import { useProblemStore } from "oj/store/problem"
|
import { useProblemStore } from "oj/store/problem"
|
||||||
import { useSubmissionStore } from "oj/store/submission"
|
import { useSubmissionStore } from "oj/store/submission"
|
||||||
import { useFlowchartWebSocket, type FlowchartEvaluationUpdate } from "shared/composables/websocket"
|
import { useFlowchartWebSocket, type FlowchartEvaluationUpdate } from "shared/composables/websocket"
|
||||||
|
import { useFallbackPoll } from "shared/composables/fallbackPoll"
|
||||||
import { useMyFlowchartStore } from "shared/store/myFlowchart"
|
import { useMyFlowchartStore } from "shared/store/myFlowchart"
|
||||||
import { useUserStore } from "shared/store/user"
|
import { useUserStore } from "shared/store/user"
|
||||||
import { useAchievementStore } from "shared/store/achievement"
|
import { useAchievementStore } from "shared/store/achievement"
|
||||||
@@ -50,8 +51,6 @@ export { isFlowchartPass }
|
|||||||
*/
|
*/
|
||||||
export type FlowchartPhase = "idle" | "evaluating" | "done" | "failed"
|
export type FlowchartPhase = "idle" | "evaluating" | "done" | "failed"
|
||||||
|
|
||||||
/** AI 评分比判题慢得多,轮询放缓一点 */
|
|
||||||
const POLL_INTERVAL = 3000
|
|
||||||
/** 到点还没结果就收手,别无限轮询下去 */
|
/** 到点还没结果就收手,别无限轮询下去 */
|
||||||
const POLL_TIMEOUT = 3 * 60 * 1000
|
const POLL_TIMEOUT = 3 * 60 * 1000
|
||||||
|
|
||||||
@@ -141,41 +140,6 @@ export const useFlowchartStore = defineStore("flowchart", () => {
|
|||||||
/** 正在跟的那一次是哪道题的,结算时再对一遍 */
|
/** 正在跟的那一次是哪道题的,结算时再对一遍 */
|
||||||
let monitoringProblemId: number | null = null
|
let monitoringProblemId: number | null = null
|
||||||
|
|
||||||
const { pause: pausePolling, resume: resumePolling } = useIntervalFn(
|
|
||||||
async () => {
|
|
||||||
if (!monitoringId) {
|
|
||||||
pausePolling()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const data = await getFlowchartSubmission(monitoringId)
|
|
||||||
if (data.status === 2) {
|
|
||||||
settle(data.id, {
|
|
||||||
ok: true,
|
|
||||||
score: data.aiScore ?? 0,
|
|
||||||
grade: data.aiGrade ?? "",
|
|
||||||
})
|
|
||||||
} else if (data.status === 3) {
|
|
||||||
settle(data.id, { ok: false })
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("[Flowchart] 轮询失败:", error)
|
|
||||||
pausePolling()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
POLL_INTERVAL,
|
|
||||||
{ immediate: false },
|
|
||||||
)
|
|
||||||
|
|
||||||
// WebSocket 正常时压根用不上轮询,先给它 5 秒,到点还没结果才开始拉
|
|
||||||
const { start: startPollingFallback, stop: stopPollingFallback } = useTimeoutFn(
|
|
||||||
() => {
|
|
||||||
if (monitoringId) resumePolling()
|
|
||||||
},
|
|
||||||
5000,
|
|
||||||
{ immediate: false },
|
|
||||||
)
|
|
||||||
|
|
||||||
const { start: startPollingDeadline, stop: stopPollingDeadline } = useTimeoutFn(
|
const { start: startPollingDeadline, stop: stopPollingDeadline } = useTimeoutFn(
|
||||||
() => {
|
() => {
|
||||||
if (!monitoringId) return
|
if (!monitoringId) return
|
||||||
@@ -191,8 +155,7 @@ export const useFlowchartStore = defineStore("flowchart", () => {
|
|||||||
monitoringId = ""
|
monitoringId = ""
|
||||||
monitoringProblemId = null
|
monitoringProblemId = null
|
||||||
unsubscribe()
|
unsubscribe()
|
||||||
pausePolling()
|
fallback.stop()
|
||||||
stopPollingFallback()
|
|
||||||
stopPollingDeadline()
|
stopPollingDeadline()
|
||||||
loading.value = false
|
loading.value = false
|
||||||
// 连接挂在 store 上,离开题目页也不会跟着组件断。不立刻断:接着还可能再交一次,
|
// 连接挂在 store 上,离开题目页也不会跟着组件断。不立刻断:接着还可能再交一次,
|
||||||
@@ -252,6 +215,8 @@ export const useFlowchartStore = defineStore("flowchart", () => {
|
|||||||
useAchievementStore().enqueue(frame.achievements ?? [])
|
useAchievementStore().enqueue(frame.achievements ?? [])
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// 订阅后服务端立刻回一帧当前状态(还在评也回 evaluation_update):WebSocket 是通的
|
||||||
|
if (monitoringId && data.submissionId === monitoringId) fallback.confirm()
|
||||||
if (data.type === "flowchart_evaluation_completed") {
|
if (data.type === "flowchart_evaluation_completed") {
|
||||||
settle(data.submissionId, {
|
settle(data.submissionId, {
|
||||||
ok: true,
|
ok: true,
|
||||||
@@ -263,9 +228,27 @@ export const useFlowchartStore = defineStore("flowchart", () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const { connect, subscribe, unsubscribe, scheduleDisconnect, cancelScheduledDisconnect } =
|
const { connect, subscribe, unsubscribe, scheduleDisconnect, cancelScheduledDisconnect, status } =
|
||||||
useFlowchartWebSocket(handleWebSocketMessage)
|
useFlowchartWebSocket(handleWebSocketMessage)
|
||||||
|
|
||||||
|
// WebSocket 通的话 20 秒才问一次,不通才快拉(见 fallbackPoll.ts)
|
||||||
|
const fallback = useFallbackPoll({
|
||||||
|
status,
|
||||||
|
poll: async () => {
|
||||||
|
if (!monitoringId) return true
|
||||||
|
const data = await getFlowchartSubmission(monitoringId)
|
||||||
|
if (data.status === 2) {
|
||||||
|
settle(data.id, { ok: true, score: data.aiScore ?? 0, grade: data.aiGrade ?? "" })
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if (data.status === 3) {
|
||||||
|
settle(data.id, { ok: false })
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
// ==================== 提交 ====================
|
// ==================== 提交 ====================
|
||||||
/** 交一张图:Mermaid 给 AI 看、给缩略图画,结构树压缩后存着(载回画布、亮路径用) */
|
/** 交一张图:Mermaid 给 AI 看、给缩略图画,结构树压缩后存着(载回画布、亮路径用) */
|
||||||
async function submit(doc: FlowDoc) {
|
async function submit(doc: FlowDoc) {
|
||||||
@@ -309,7 +292,7 @@ export const useFlowchartStore = defineStore("flowchart", () => {
|
|||||||
monitoringId = response.submissionId
|
monitoringId = response.submissionId
|
||||||
monitoringProblemId = problem.id
|
monitoringProblemId = problem.id
|
||||||
subscribe(response.submissionId)
|
subscribe(response.submissionId)
|
||||||
startPollingFallback()
|
fallback.start()
|
||||||
startPollingDeadline()
|
startPollingDeadline()
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import { getCurrentScope, onScopeDispose, watch, type Ref } from "vue"
|
||||||
|
import { useDocumentVisibility } from "@vueuse/core"
|
||||||
|
import type { ConnectionStatus } from "shared/composables/websocket"
|
||||||
|
|
||||||
|
/** 交上去之后先等 WebSocket 这么久,再开始兜底 */
|
||||||
|
const GRACE = 5000
|
||||||
|
/** WebSocket 不通时从这个间隔起拉,每次乘 1.5,最慢到 MAX_FAST */
|
||||||
|
const MIN_FAST = 2000
|
||||||
|
const MAX_FAST = 10_000
|
||||||
|
/** WebSocket 好好的:结果会推过来,轮询只是防 pub/sub 丢那一条,隔这么久问一次 */
|
||||||
|
const SLOW = 20_000
|
||||||
|
/** 连着失败这么多次就不拉了 */
|
||||||
|
const MAX_ERRORS = 5
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判题、流程图评分的结果「WebSocket 推、轮询兜底」里的兜底那一半。
|
||||||
|
*
|
||||||
|
* 原来是交上去 5 秒还没结果就每 2 秒(流程图 3 秒)拉一次,分不清「WebSocket 断了」和
|
||||||
|
* 「队列排得长」。服务器只有两核,一个班 50 人一起交时判题队列要排一分钟,几乎每个人都
|
||||||
|
* 进了轮询,每秒二十几个请求全在和判题抢 CPU。
|
||||||
|
*
|
||||||
|
* 能分清的依据:服务端收到 subscribe 会立刻回一帧当前状态(pending / judging 也回),
|
||||||
|
* 断线重连后 websocket.ts 会自动重新订阅、再回一帧。所以调用方收到**这一条**的任何帧就
|
||||||
|
* `confirm()`,此后只要连接没断,就知道结果会推过来,轮询退到 20 秒一次;连接一断立刻
|
||||||
|
* 回到快档。页面藏在后台时不拉:切回来时 WebSocket 会自己重连、重放当前状态。
|
||||||
|
*
|
||||||
|
* `poll` 返回 true 表示有结果了、不用再拉。抛错按快档退避接着拉(掉线那几秒拉失败是
|
||||||
|
* 常事,原来一次失败就永久停掉,之后要是又丢了推送就一直转圈),连着失败
|
||||||
|
* MAX_ERRORS 次才放弃,别对着一个坏掉的接口一直刷。
|
||||||
|
*/
|
||||||
|
export function useFallbackPoll(options: {
|
||||||
|
status: Ref<ConnectionStatus>
|
||||||
|
poll: () => Promise<boolean>
|
||||||
|
}) {
|
||||||
|
const visibility = useDocumentVisibility()
|
||||||
|
let timer: ReturnType<typeof setTimeout> | undefined
|
||||||
|
let active = false
|
||||||
|
let confirmed = false
|
||||||
|
let fastDelay = MIN_FAST
|
||||||
|
let lastPolledAt = 0
|
||||||
|
let errors = 0
|
||||||
|
|
||||||
|
const healthy = () => confirmed && options.status.value === "connected"
|
||||||
|
|
||||||
|
function schedule(delay: number) {
|
||||||
|
clearTimeout(timer)
|
||||||
|
timer = setTimeout(tick, delay)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function tick() {
|
||||||
|
if (!active) return
|
||||||
|
if (visibility.value === "hidden") {
|
||||||
|
schedule(SLOW)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 刚失败过的不算健康:WebSocket 看着连着,拉却失败了,多半是网刚断、它还没察觉
|
||||||
|
if (healthy() && errors === 0 && Date.now() - lastPolledAt < SLOW) {
|
||||||
|
schedule(SLOW - (Date.now() - lastPolledAt))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
lastPolledAt = Date.now()
|
||||||
|
try {
|
||||||
|
if (await options.poll()) {
|
||||||
|
stop()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
errors = 0
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[FallbackPoll] 轮询失败:", error)
|
||||||
|
if (++errors >= MAX_ERRORS) {
|
||||||
|
stop()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!active) return
|
||||||
|
if (healthy() && errors === 0) {
|
||||||
|
schedule(SLOW)
|
||||||
|
} else {
|
||||||
|
schedule(fastDelay)
|
||||||
|
fastDelay = Math.min(MAX_FAST, fastDelay * 1.5)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 开始跟一条新的。lastPolledAt 记成现在:WebSocket 一直好的话,第一次拉在 20 秒后 */
|
||||||
|
function start() {
|
||||||
|
active = true
|
||||||
|
confirmed = false
|
||||||
|
fastDelay = MIN_FAST
|
||||||
|
errors = 0
|
||||||
|
lastPolledAt = Date.now()
|
||||||
|
schedule(GRACE)
|
||||||
|
}
|
||||||
|
|
||||||
|
function stop() {
|
||||||
|
active = false
|
||||||
|
clearTimeout(timer)
|
||||||
|
timer = undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 收到了这一条的推送:WebSocket 是通的 */
|
||||||
|
function confirm() {
|
||||||
|
confirmed = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 连接一断,之前的确认就不算数了,回到快档;重连上之后的重放帧会再确认一次。
|
||||||
|
// 本来就没确认的不管:连不上时每次重连都要走一遍 connecting → error → disconnected,
|
||||||
|
// 在这里重置的话退避永远退不上去
|
||||||
|
watch(options.status, (status) => {
|
||||||
|
if (!active || !confirmed || status === "connected") return
|
||||||
|
confirmed = false
|
||||||
|
fastDelay = MIN_FAST
|
||||||
|
schedule(MIN_FAST)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 切回前台:WebSocket 还没确认的话别等满 20 秒
|
||||||
|
watch(visibility, (value) => {
|
||||||
|
if (active && value === "visible" && !healthy()) schedule(MIN_FAST)
|
||||||
|
})
|
||||||
|
|
||||||
|
if (getCurrentScope()) onScopeDispose(stop)
|
||||||
|
|
||||||
|
return { start, stop, confirm }
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { watch } from "vue"
|
||||||
|
import { useDocumentVisibility, useIntervalFn } from "@vueuse/core"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 定时刷新,但页面藏在后台时不拉,切回来立刻补一次。
|
||||||
|
*
|
||||||
|
* 比赛榜单 10 秒一次、比赛班级页 15 秒一次,原来在后台标签页里照样拉:考试时一个班
|
||||||
|
* 几十个人开着题目页又开着榜单,后台那几十个请求是白付的,服务器只有两核。
|
||||||
|
* 用法和 useIntervalFn 一样,默认不立即开始,由调用方 resume()。
|
||||||
|
*/
|
||||||
|
export function useVisibleInterval(fn: () => unknown, interval: number) {
|
||||||
|
const visibility = useDocumentVisibility()
|
||||||
|
const { pause, resume, isActive } = useIntervalFn(
|
||||||
|
() => {
|
||||||
|
if (visibility.value === "visible") fn()
|
||||||
|
},
|
||||||
|
interval,
|
||||||
|
{ immediate: false },
|
||||||
|
)
|
||||||
|
watch(visibility, (value) => {
|
||||||
|
if (value === "visible" && isActive.value) fn()
|
||||||
|
})
|
||||||
|
return { pause, resume }
|
||||||
|
}
|
||||||
Reference in new issue
Block a user