fix(api): прекращать диагностику при отмене HTTP

This commit is contained in:
Denozordec committed 2026-10-01 09:00:19 +07:00
1 parent 9d2101d700
commit 440e10c646
6 files changed
+89 -11

No files matched your search

+1 -1
View File
@@ -13,7 +13,7 @@
"db:studio": "drizzle-kit studio",
"db:migrate-from-sqlite": "tsx src/scripts/migrate-sqlite-to-pg.ts",
"facts:rebuild": "tsx src/scripts/rebuild-flow-facts.ts",
"test:probes": "tsx src/routes/probes.test.ts",
"test:probes": "tsx src/routes/probes.test.ts && tsx src/routes/probes.cancel.test.ts",
"test:exec": "tsx src/routes/exec.test.ts && tsx src/plugins/auth.exec.test.ts",
"test:auth": "tsx src/lib/permissions.test.ts && tsx src/plugins/auth.smoke.test.ts && tsx src/plugins/auth.ospf-optimizer.test.ts",
"test:wireguard": "npx tsx src/services/wireguard-config.test.ts",
+44
View File
@@ -0,0 +1,44 @@
import assert from "node:assert/strict"
import Fastify from "fastify"
import {validatorCompiler,serializerCompiler} from "@fastify/type-provider-zod"
import {createProbeRoutes,type ProbeDependencies} from "./probes.js"
import type {servers} from "../db/schema.js"
const server={id:1,name:"Тест",host:"192.0.2.1",enabled:true,username:"test",password:"test"} as typeof servers.$inferSelect
let mode="normal",calls=0,aborts=0,resolveStarted!:()=>void,started=Promise.resolve(),resolveAborted!:()=>void,aborted=Promise.resolve()
function waitSignal(signal?:AbortSignal){
assert.ok(signal,"HTTP отмена передана в RouterOS клиент");assert.equal(signal.aborted,false,"тело HTTP прочитано, но ответ ещё ожидается");calls++;resolveStarted()
if(mode==="normal")return Promise.resolve([{seq:"0",received:"1",time:"1ms"}])
return new Promise<unknown>((_resolve,reject)=>{const timer=setTimeout(()=>reject(new Error("Отмена не доставлена")),2000);signal.addEventListener("abort",()=>{clearTimeout(timer);aborts++;resolveAborted();reject(new DOMException("aborted","AbortError"))},{once:true})})
}
const client={post:async(_path:unknown,_body:unknown,_timeout:unknown,signal?:AbortSignal)=>waitSignal(signal),getIpRoutes:async()=>[],bandwidthTest:async(_params:unknown,signal?:AbortSignal)=>waitSignal(signal)} as ReturnType<NonNullable<ProbeDependencies["createClient"]>>
const app=Fastify();app.setValidatorCompiler(validatorCompiler);app.setSerializerCompiler(serializerCompiler)
await app.register(createProbeRoutes({getServer:async()=>server,createClient:()=>client,resolveSource:async()=>"192.0.2.1",findPeer:async()=>server}),{prefix:"/api"})
const address=await app.listen({host:"127.0.0.1",port:0})
async function send(tool:string,signal?:AbortSignal){return fetch(address+"/api/servers/1/probes/run",{method:"POST",headers:{"content-type":"application/json",connection:"close"},body:JSON.stringify({tool,target:"192.0.2.2",bwRemoteAddress:"192.0.2.2"}),signal})}
try{
resolveStarted=()=>{};const normal=await send("traceroute");assert.equal(normal.status,200);assert.ok((await normal.json()).output)
mode="cancel"
for(const tool of ["ping","traceroute","mtu","bandwidth"]){
started=new Promise<void>(r=>resolveStarted=r);aborted=new Promise<void>(r=>resolveAborted=r);const before=calls,beforeAborts=aborts,controller=new AbortController()
const pending=send(tool,controller.signal).then(()=>{throw new Error("Отменённый HTTP не должен завершаться успехом")},error=>{assert.equal(error.name,"AbortError")})
await started;controller.abort();await Promise.race([aborted,new Promise<void>((_r,reject)=>setTimeout(()=>reject(new Error("Нет backend abort")),2500))]);await pending
assert.equal(aborts,beforeAborts+1);assert.equal(calls,before+1,tool+": следующие пробы не запущены")
}
}finally{await app.close()}
console.log("Probes local HTTP: normal trace body does not abort; browser disconnect reaches ping/trace/MTU/BW client and stops further probes; no RouterOS")
const {createServer}=await import("node:http")
const {MikrotikClient}=await import("../services/mikrotik.js")
let arrived!:()=>void,disconnected!:()=>void
const arrival=new Promise<void>(r=>arrived=r),disconnection=new Promise<void>(r=>disconnected=r)
const ros=createServer((req,res)=>{req.resume();req.once("end",arrived);res.once("close",disconnected)})
await new Promise<void>(r=>ros.listen(0,"127.0.0.1",r))
try{
const addr=ros.address();assert.ok(addr&&typeof addr!=="string")
const liveClient=new MikrotikClient({host:"127.0.0.1",port:addr.port,useSsl:false,verifySsl:false,username:"fixture",password:"fixture"})
const controller=new AbortController()
const pending=liveClient.bandwidthTest({address:"192.0.2.2",user:"fixture",password:"fixture"},controller.signal).then(()=>{throw new Error("BW не должен завершаться успехом")},error=>assert.match(error.message,/abort/i))
await arrival;controller.abort();await pending
await Promise.race([disconnection,new Promise<void>((_r,reject)=>setTimeout(()=>reject(new Error("RouterOS HTTP не закрыт")),2000))])
}finally{await new Promise<void>((resolve,reject)=>ros.close(error=>error?reject(error):resolve()))}
console.log("BW transport AbortSignal: real MikrotikClient POST closed against local fake RouterOS")
+24 -7
View File
@@ -254,11 +254,12 @@ async function dnsLookupText(name: string, type: string): Promise<string> {
return lines.join("\n")
}
async function mtuDiscover(client: ProbeClient, address: string, srcIpv4: string | null): Promise<string> {
async function mtuDiscover(client: ProbeClient, address: string, srcIpv4: string | null, signal?: AbortSignal): Promise<string> {
const sizes = [1500, 1492, 1480, 1476, 1472, 1468, 1400, 1280, 1024, 576]
const lines: string[] = [`MTU discovery (do-not-fragment ping) → ${address}`, ""]
let mtuFound: number | null = null
for (const size of sizes) {
signal?.throwIfAborted()
try {
const body: Record<string, string> = {
address,
@@ -269,7 +270,7 @@ async function mtuDiscover(client: ProbeClient, address: string, srcIpv4: string
"do-not-fragment": "true",
}
if (srcIpv4) body["src-address"] = srcIpv4
const rows = await client.post<RosPingResult[]>("/tool/ping", body, 25_000)
const rows = await client.post<RosPingResult[]>("/tool/ping", body, 25_000, signal)
const timeout = rows.some((r) => r.status === "timeout")
const received = rows.some(r => /^\d+$/.test(String(r.received ?? "")) && Number(r.received) === 1)
const reply = rows.some(r => /^[01]$/.test(String(r.seq ?? "")) && !r.status && /^(?:\d+(?:\.\d+)?(?:us|ms|s)|\d{1,2}:\d{2}:\d{2}(?:\.\d+)?)$/.test(String(r.time ?? "")))
@@ -327,9 +328,18 @@ export function createProbeRoutes(deps: ProbeDependencies = {}): FastifyPluginAs
if (!server) return reply.status(404).send({ error: "Server not found" })
if (!server.enabled) return reply.status(409).send({ error: "Устройство выключено. Диагностика недоступна." })
const tool = body.tool
const requestAbort = new AbortController()
const onRequestAborted = () => requestAbort.abort()
const onResponseClose = () => { if (!reply.raw.writableEnded) requestAbort.abort() }
req.raw.once("aborted", onRequestAborted)
reply.raw.once("close", onResponseClose)
if (req.raw.aborted || reply.raw.destroyed) requestAbort.abort()
const signal = requestAbort.signal
try {
signal.throwIfAborted()
const client = createClient(server)
const srcIpv4 = ["ping", "traceroute", "mtu"].includes(tool) ? await resolveSource(server.host) : null
signal.throwIfAborted()
let output = ""
switch (tool) {
@@ -347,7 +357,7 @@ export function createProbeRoutes(deps: ProbeDependencies = {}): FastifyPluginAs
interval: "0.2s",
}
if (srcIpv4) pingBody["src-address"] = srcIpv4
const rows = await client.post<RosPingResult[]>("/tool/ping", pingBody, 120_000)
const rows = await client.post<RosPingResult[]>("/tool/ping", pingBody, 120_000, signal)
output = fmtPingRouterOs(rows, target)
break
}
@@ -376,7 +386,8 @@ export function createProbeRoutes(deps: ProbeDependencies = {}): FastifyPluginAs
if (srcIpv4) traceBody["src-address"] = srcIpv4
const disconnectAbort = new AbortController()
const onClientClose = () => disconnectAbort.abort()
req.raw.once("close", onClientClose)
signal.addEventListener("abort", onClientClose, { once: true })
if (signal.aborted) disconnectAbort.abort()
const traceTimeout = setTimeout(() => disconnectAbort.abort(), traceWallMs)
try {
const sig = disconnectAbort.signal
@@ -388,7 +399,7 @@ export function createProbeRoutes(deps: ProbeDependencies = {}): FastifyPluginAs
output = note + fmtTraceroute(rows)
} finally {
clearTimeout(traceTimeout)
req.raw.off("close", onClientClose)
signal.removeEventListener("abort", onClientClose)
}
break
}
@@ -402,6 +413,7 @@ export function createProbeRoutes(deps: ProbeDependencies = {}): FastifyPluginAs
})
}
if (!dst.enabled) return reply.status(409).send({ error: "Устройство назначения выключено. BW-тест недоступен." })
signal.throwIfAborted()
const protocol = body.bwProto === "udp" ? "udp" : "tcp"
const durationSec = Math.max(3, Math.min(120, Number(body.bwDuration) || 10))
const rows = await client.bandwidthTest({
@@ -411,7 +423,7 @@ export function createProbeRoutes(deps: ProbeDependencies = {}): FastifyPluginAs
protocol,
direction: "both",
durationSec,
})
}, signal)
output = fmtBandwidth(rows)
break
}
@@ -432,13 +444,14 @@ export function createProbeRoutes(deps: ProbeDependencies = {}): FastifyPluginAs
case "mtu": {
const target = String(body.target ?? "").trim()
if (!target) return reply.status(400).send({ error: "target required" })
output = await mtuDiscover(client, target, srcIpv4)
output = await mtuDiscover(client, target, srcIpv4, signal)
break
}
default:
return reply.status(400).send({ error: `unknown tool: ${tool}` })
}
signal.throwIfAborted()
if (/^;;\s*ERROR:/im.test(output) || /^error:/i.test(output.trimStart())) return reply.status(502).send({ error: output })
return reply.send({ output })
} catch (err) {
@@ -448,7 +461,11 @@ export function createProbeRoutes(deps: ProbeDependencies = {}): FastifyPluginAs
: err instanceof Error
? err.message
: String(err)
if (reply.raw.destroyed) return
return reply.status(502).send({ error: msg })
} finally {
req.raw.off("aborted", onRequestAborted)
reply.raw.off("close", onResponseClose)
}
})
}
+2 -2
View File
@@ -704,7 +704,7 @@ export class MikrotikClient {
protocol?: "tcp" | "udp"
direction?: "transmit" | "receive" | "both"
durationSec?: number
}): Promise<Array<Record<string, string>>> {
}, signal?: AbortSignal): Promise<Array<Record<string, string>>> {
const body: Record<string, string> = {
address: params.address,
user: params.user,
@@ -713,7 +713,7 @@ export class MikrotikClient {
direction: params.direction ?? "both",
duration: `${Math.max(3, params.durationSec ?? 10)}s`,
}
return this.post<Array<Record<string, string>>>("/tool/bandwidth-test", body, 30_000)
return this.post<Array<Record<string, string>>>("/tool/bandwidth-test", body, 30_000, signal)
}
async getCertificates(): Promise<Array<Record<string, string | undefined>>> {