From 440e10c6462faa3783fa8728b7a9e5e86a7286a6 Mon Sep 17 00:00:00 2001 From: Denozordec Date: Thu, 1 Oct 2026 09:00:19 +0700 Subject: [PATCH] =?UTF-8?q?fix(api):=20=D0=BF=D1=80=D0=B5=D0=BA=D1=80?= =?UTF-8?q?=D0=B0=D1=89=D0=B0=D1=82=D1=8C=20=D0=B4=D0=B8=D0=B0=D0=B3=D0=BD?= =?UTF-8?q?=D0=BE=D1=81=D1=82=D0=B8=D0=BA=D1=83=20=D0=BF=D1=80=D0=B8=20?= =?UTF-8?q?=D0=BE=D1=82=D0=BC=D0=B5=D0=BD=D0=B5=20HTTP?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/package.json | 2 +- backend/src/routes/probes.cancel.test.ts | 44 ++++++++++++++++++++++++ backend/src/routes/probes.ts | 31 +++++++++++++---- backend/src/services/mikrotik.ts | 4 +-- docs/ux-ui-progress.md | 10 +++++- docs/ux-ui-stage-10.md | 9 +++++ 6 files changed, 89 insertions(+), 11 deletions(-) create mode 100644 backend/src/routes/probes.cancel.test.ts diff --git a/backend/package.json b/backend/package.json index 8974f144..f464a999 100644 --- a/backend/package.json +++ b/backend/package.json @@ -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", diff --git a/backend/src/routes/probes.cancel.test.ts b/backend/src/routes/probes.cancel.test.ts new file mode 100644 index 00000000..64a28685 --- /dev/null +++ b/backend/src/routes/probes.cancel.test.ts @@ -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((_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> +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(r=>resolveStarted=r);aborted=new Promise(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((_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(r=>arrived=r),disconnection=new Promise(r=>disconnected=r) +const ros=createServer((req,res)=>{req.resume();req.once("end",arrived);res.once("close",disconnected)}) +await new Promise(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((_r,reject)=>setTimeout(()=>reject(new Error("RouterOS HTTP не закрыт")),2000))]) +}finally{await new Promise((resolve,reject)=>ros.close(error=>error?reject(error):resolve()))} +console.log("BW transport AbortSignal: real MikrotikClient POST closed against local fake RouterOS") diff --git a/backend/src/routes/probes.ts b/backend/src/routes/probes.ts index b5a53cbd..7ea37ae5 100644 --- a/backend/src/routes/probes.ts +++ b/backend/src/routes/probes.ts @@ -254,11 +254,12 @@ async function dnsLookupText(name: string, type: string): Promise { return lines.join("\n") } -async function mtuDiscover(client: ProbeClient, address: string, srcIpv4: string | null): Promise { +async function mtuDiscover(client: ProbeClient, address: string, srcIpv4: string | null, signal?: AbortSignal): Promise { 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 = { 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("/tool/ping", body, 25_000) + const rows = await client.post("/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("/tool/ping", pingBody, 120_000) + const rows = await client.post("/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) } }) } diff --git a/backend/src/services/mikrotik.ts b/backend/src/services/mikrotik.ts index 174a3cb6..4b54caa4 100644 --- a/backend/src/services/mikrotik.ts +++ b/backend/src/services/mikrotik.ts @@ -704,7 +704,7 @@ export class MikrotikClient { protocol?: "tcp" | "udp" direction?: "transmit" | "receive" | "both" durationSec?: number - }): Promise>> { + }, signal?: AbortSignal): Promise>> { const body: Record = { 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>>("/tool/bandwidth-test", body, 30_000) + return this.post>>("/tool/bandwidth-test", body, 30_000, signal) } async getCertificates(): Promise>> { diff --git a/docs/ux-ui-progress.md b/docs/ux-ui-progress.md index 6f2946c8..e27fd886 100644 --- a/docs/ux-ui-progress.md +++ b/docs/ux-ui-progress.md @@ -26,7 +26,7 @@ | 7 | Серверы и мониторинг: детали, формы, URL-контекст, права | Завершён в границах локальной приёмки 2026-09-23; внешние ограничения ниже | | 8 | Трафик и статистика | Завершён 2026-09-28 в границах локальной приёмки; ограничения в блоке 8.13 | | 9 | VPN, Firewall и Filters | Завершён в границах локальной приёмки 2026-09-30; блоки 9.1–9.64, ограничения в 9.64 | -| 10 | Маршрутизация, диагностика, карта и терминал | В работе: 10.1–10.67; оптимизатор, карта и терминал приняты локально; диагностика /probes в работе | +| 10 | Маршрутизация, диагностика, карта и терминал | В работе: 10.1–10.68; оптимизатор, карта и терминал приняты локально; диагностика /probes в работе | | 11А | Пользователи, контейнеры, сертификаты и резервные копии | Не начат | | 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат | | 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат | @@ -36,6 +36,14 @@ +## Блок 10.68 — отмена backend диагностики (2026-10-01) + +- Abort привязан к aborted HTTP запросу/незавершённому response close; нормальное завершение чтения тела не считается отменой. Listeners снимаются в finally. Перед/после зависимых await проверяется сигнал; отмена не начинает новые MTU пробы. +- Ping/traceroute/MTU получают AbortSignal в post; bandwidthTest принимает необязательный сигнал и передаёт его в существующий post. Старые вызовы совместимы, таймауты/allowlist/параметры сохранены. DNS и route GET не обещают физическую отмену текущего upstream запроса, после закрытия ответа результат не подтверждается. +- Локальные TCP HTTP тесты доказали: обычный trace body не отменяет запрос; закрытие browser fetch доходит до mock RouterOS клиента для ping/trace/MTU/BW; MTU выполняет только одну пробу. Настоящий MikrotikClient BW POST закрывается при abort на локальном fake HTTP RouterOS. Это не подтверждение остановки реального RouterOS процесса. +- Полный backend npm test завершился exit 0; DB-зависимые тесты явно skipped из-за ECONNREFUSED PostgreSQL 127.0.0.1:5432, не приняты по этому прогону. test:probes/build/profile lint повторены после транспортного теста. Mikrotik service сохраняет две исходные prefer-const ошибки 117/874; ESLint HEAD/current совпал, suppressions не добавлены. +- Далее 10.69: доступность и адаптивность диагностики, семантические токены, подписи/вывод/clipboard и снимки; затем профильная регрессия и локальная приёмка /probes. Расписание в 11Б, реальные RouterOS/БД/портал остаются внешними ограничениями. + ## Блок 10.67 — неизвестные измерения и маршрутный срез (2026-10-01) - Ping сохраняет seq=0/TTL=0/sent=0/received=0, отсутствующие/невалидные счётчики помечает ?, пустой ответ подписан без измерений. Нет ложного avg-rtt из поля time суммарной строки. BW/trace пустые показатели показывают —, известный ноль сохранён. diff --git a/docs/ux-ui-stage-10.md b/docs/ux-ui-stage-10.md index 22c7f433..6917aa11 100644 --- a/docs/ux-ui-stage-10.md +++ b/docs/ux-ui-stage-10.md @@ -424,3 +424,12 @@ Shared контракт и backend observed исходные значения/д - Backend test:probes/build/lint прошли; DI HTTP проверяет нули/unknown ping/BW, плохой target/CIDR, mixed known/unknown маршруты и приоритеты. Моки не проверяют реальную FIB/RouterOS/IP. - Далее 10.68: корректная привязка отмены к жизненному циклу HTTP ответа и прекращение последующих MTU проб; затем клавиатура/адаптивность и локальная приёмка /probes. Этап 10 ещё в работе. + +## Блок 10.68 — отмена backend диагностики (2026-10-01) + +- Abort привязан к aborted HTTP запросу/незавершённому response close; нормальное завершение чтения тела не считается отменой. Listeners снимаются в finally. Перед/после зависимых await проверяется сигнал; отмена не начинает новые MTU пробы. +- Ping/traceroute/MTU получают AbortSignal в post; bandwidthTest принимает необязательный сигнал и передаёт его в существующий post. Старые вызовы совместимы, таймауты/allowlist/параметры сохранены. DNS и route GET не обещают физическую отмену текущего upstream запроса, после закрытия ответа результат не подтверждается. +- Локальные TCP HTTP тесты доказали: обычный trace body не отменяет запрос; закрытие browser fetch доходит до mock RouterOS клиента для ping/trace/MTU/BW; MTU выполняет только одну пробу. Настоящий MikrotikClient BW POST закрывается при abort на локальном fake HTTP RouterOS. Это не подтверждение остановки реального RouterOS процесса. +- Полный backend npm test завершился exit 0; DB-зависимые тесты явно skipped из-за ECONNREFUSED PostgreSQL 127.0.0.1:5432, не приняты по этому прогону. test:probes/build/profile lint повторены после транспортного теста. Mikrotik service сохраняет две исходные prefer-const ошибки 117/874; ESLint HEAD/current совпал, suppressions не добавлены. +- Далее 10.69: доступность и адаптивность диагностики, семантические токены, подписи/вывод/clipboard и снимки; затем профильная регрессия и локальная приёмка /probes. Расписание в 11Б, реальные RouterOS/БД/портал остаются внешними ограничениями. +