diff --git a/plugins/file-manager/src/backend/content-routes.ts b/plugins/file-manager/src/backend/content-routes.ts index 6e8f55d2b..52627730f 100644 --- a/plugins/file-manager/src/backend/content-routes.ts +++ b/plugins/file-manager/src/backend/content-routes.ts @@ -1244,7 +1244,7 @@ export function registerFileContentRoutes( let destroyUpload: (() => void) | undefined; const abortUpload = () => { - if (req.complete || cleanupStarted) return; + if (resolved || cleanupStarted) return; requestAborted = true; cleanupStarted = true; destroyUpload?.(); @@ -1252,7 +1252,12 @@ export function registerFileContentRoutes( req.once("aborted", abortUpload); req.once("error", abortUpload); - req.once("close", abortUpload); + req.once("close", () => { + if (!req.complete) abortUpload(); + }); + res.once("close", () => { + if (!res.writableFinished) abortUpload(); + }); const bb = Busboy({ headers: req.headers }); @@ -1311,6 +1316,10 @@ export function registerFileContentRoutes( getSessionSftp(sshConn) .then((sftp) => { + if (requestAborted) { + fileStream.resume(); + return; + } const writeStream = sftp.createWriteStream(fullPath); const removePartialFile = () => { writeStream.destroy(); @@ -1325,11 +1334,6 @@ export function registerFileContentRoutes( }; destroyUpload = removePartialFile; - if (requestAborted) { - removePartialFile(); - return; - } - writeStream.on("error", (err) => { ctx.log.error("SFTP write stream error during upload:", err); if (!resolved && !requestAborted) { @@ -1460,12 +1464,24 @@ export function registerFileContentRoutes( let bytesWritten = 0; const uploadStartTime = Date.now(); + let destroyUpload: (() => void) | undefined; + res.once("close", () => { + if (resolved || res.writableFinished) return; + resolved = true; + destroyUpload?.(); + }); + getSessionSftp(sshConn) .then((sftp) => { + if (resolved) return; const writeStream = sftp.createWriteStream(fullPath, { flags: offset === 0 ? "w" : "r+", start: offset, }); + destroyUpload = () => { + req.unpipe(writeStream as unknown as NodeJS.WritableStream); + writeStream.destroy(); + }; const fail = (status: number, error: string) => { if (resolved) return; diff --git a/plugins/file-manager/src/frontend/FileManager.tsx b/plugins/file-manager/src/frontend/FileManager.tsx index d2eaed209..aaf95888c 100644 --- a/plugins/file-manager/src/frontend/FileManager.tsx +++ b/plugins/file-manager/src/frontend/FileManager.tsx @@ -1185,9 +1185,14 @@ function FileManagerContent({ if (files.length === 0 && emptyDirs.length === 0) return; + const controller = new AbortController(); + const cancel = { + label: t("fileManager.cancel"), + onClick: () => controller.abort(), + }; const progressToast = toast.loading( t("fileManager.uploadingFolderFiles", { count: files.length }), - { duration: Infinity }, + { duration: Infinity, cancel }, ); const failed: string[] = []; @@ -1214,6 +1219,7 @@ function FileManagerContent({ a.split("/").length - b.split("/").length || a.localeCompare(b), ); for (const dir of sortedDirs) { + controller.signal.throwIfAborted(); const parentDir = dir.split("/").slice(0, -1).join("/"); const targetPath = parentDir ? `${base}${parentDir}/` : base; const folderName = dir.split("/").pop()!; @@ -1226,6 +1232,7 @@ function FileManagerContent({ } for (const { file, relativePath } of files) { + controller.signal.throwIfAborted(); const dirPart = relativePath.includes("/") ? relativePath.substring(0, relativePath.lastIndexOf("/")) : ""; @@ -1238,13 +1245,18 @@ function FileManagerContent({ file.name, file, currentHost?.id, + undefined, + undefined, + controller.signal, ); } catch (error) { + controller.signal.throwIfAborted(); failed.push(relativePath); console.error(`Failed to upload ${relativePath}:`, error); } } + controller.signal.throwIfAborted(); toast.dismiss(progressToast); if (failed.length === 0) { toast.success( @@ -1263,6 +1275,11 @@ function FileManagerContent({ handleRefreshDirectory(); } catch (error) { toast.dismiss(progressToast); + if (controller.signal.aborted) { + toast.info(t("fileManager.localTransferCancelled")); + handleRefreshDirectory(); + return; + } toast.error(t("fileManager.failedToUploadFile")); console.error("Folder upload failed:", error); } @@ -1280,12 +1297,17 @@ function FileManagerContent({ async function handleUploadFile(file: File) { if (!sshSessionId) return; + const controller = new AbortController(); + const cancel = { + label: t("fileManager.cancel"), + onClick: () => controller.abort(), + }; const progressToast = toast.loading( t("fileManager.uploadingFile", { name: file.name, size: formatFileSize(file.size), }), - { duration: Infinity }, + { duration: Infinity, cancel }, ); const updateProgress = (p: { @@ -1294,13 +1316,14 @@ function FileManagerContent({ bytesSent: number; totalBytes: number; }) => { + if (controller.signal.aborted) return; const percent = Math.min( 100, Math.round((p.bytesSent / p.totalBytes) * 100), ); toast.loading( `Uploading ${file.name} — ${percent}% (chunk ${p.chunkIndex + 1}/${p.totalChunks})`, - { id: progressToast, duration: Infinity }, + { id: progressToast, duration: Infinity, cancel }, ); }; @@ -1315,7 +1338,9 @@ function FileManagerContent({ currentHost?.id, undefined, updateProgress, + controller.signal, ); + controller.signal.throwIfAborted(); toast.dismiss(progressToast); @@ -1325,6 +1350,11 @@ function FileManagerContent({ handleRefreshDirectory(); } catch (error: unknown) { toast.dismiss(progressToast); + if (controller.signal.aborted) { + toast.info(t("fileManager.localTransferCancelled")); + handleRefreshDirectory(); + return; + } const uploadErr = error instanceof Error ? error : null; if ( uploadErr?.message?.includes("connection") || diff --git a/plugins/file-manager/src/frontend/api/ssh-file-operations-api.ts b/plugins/file-manager/src/frontend/api/ssh-file-operations-api.ts index 65441c21f..c2a22caa2 100644 --- a/plugins/file-manager/src/frontend/api/ssh-file-operations-api.ts +++ b/plugins/file-manager/src/frontend/api/ssh-file-operations-api.ts @@ -358,6 +358,7 @@ export async function uploadSSHFile( bytesSent: number; totalBytes: number; }) => void, + signal?: AbortSignal, ): Promise> { // Browser-side safety: any single multipart body approaching 2^31 bytes (~2.14GB) // crashes the XHR/ArrayBuffer pipeline in both Chromium (Electron) and Firefox, @@ -368,6 +369,7 @@ export async function uploadSSHFile( const CHUNK_SIZE_BYTES = 8 * 1024 * 1024; // 8 MiB try { + signal?.throwIfAborted(); if (file.size > CHUNK_THRESHOLD_BYTES) { const totalChunks = Math.ceil(file.size / CHUNK_SIZE_BYTES); fileLogger.info("Starting chunked upload", { @@ -380,6 +382,7 @@ export async function uploadSSHFile( let bytesSent = 0; for (let i = 0; i < totalChunks; i++) { + signal?.throwIfAborted(); const start = i * CHUNK_SIZE_BYTES; const end = Math.min(start + CHUNK_SIZE_BYTES, file.size); const chunkBlob = file.slice(start, end); @@ -397,6 +400,7 @@ export async function uploadSSHFile( }, headers: { "Content-Type": "application/octet-stream" }, timeout: 0, + signal, }, ); @@ -433,6 +437,7 @@ export async function uploadSSHFile( form, { timeout: 0, + signal, onUploadProgress: (event) => { const totalBytes = typeof event.total === "number" && event.total > 0 @@ -449,6 +454,7 @@ export async function uploadSSHFile( ); return response.data; } catch (error) { + if (signal?.aborted) throw error; handleApiError(error, "upload SSH file"); } } diff --git a/plugins/file-manager/tests/backend/upload-cancellation.test.ts b/plugins/file-manager/tests/backend/upload-cancellation.test.ts new file mode 100644 index 000000000..b04be386f --- /dev/null +++ b/plugins/file-manager/tests/backend/upload-cancellation.test.ts @@ -0,0 +1,141 @@ +import { EventEmitter } from "node:events"; +import { PassThrough, Writable } from "node:stream"; +import type { Express, Request, Response } from "express"; +import { describe, expect, it, vi } from "vitest"; +import type { PluginContext } from "@termix/plugin-sdk/backend"; +import { registerFileContentRoutes } from "../../src/backend/content-routes"; +import type { SSHSession } from "../../src/backend/session"; + +const { getSessionSftp } = vi.hoisted(() => ({ getSessionSftp: vi.fn() })); +vi.mock("../../src/backend/session.js", () => ({ getSessionSftp })); + +function setup(route = "/uploadFileChunk") { + const routes = new Map void>(); + const app = { + get: vi.fn(), + post: (path: string, handler: (req: Request, res: Response) => void) => + routes.set(path, handler), + } as unknown as Express; + registerFileContentRoutes(app, { + ctx: { + currentActor: () => "user-1", + log: { info: vi.fn(), error: vi.fn() }, + } as unknown as PluginContext, + sshSessions: { s: { isConnected: true } as SSHSession }, + verifySessionOwnership: () => true, + }); + const req = Object.assign(new PassThrough(), { + query: { + sessionId: "s", + path: "/", + fileName: "upload.bin", + offset: "0", + totalSize: "4", + }, + headers: { "content-type": "multipart/form-data; boundary=test-boundary" }, + body: Buffer.from("data"), + complete: true, + }); + const res = Object.assign(new EventEmitter(), { + writableFinished: false, + json: vi.fn(), + status: vi.fn().mockReturnThis(), + }); + const start = () => + routes.get(route)!(req as unknown as Request, res as unknown as Response); + return { req, res, start }; +} + +function sftp() { + // Keep the SSH write pending after HTTP has received the complete body. + const stream = new Writable({ write() {} }); + return { stream, createWriteStream: vi.fn(() => stream) }; +} + +describe("chunk upload disconnects", () => { + it("stops SSH writes when the client disconnects after sending its body", async () => { + const remote = sftp(); + getSessionSftp.mockResolvedValue(remote); + const { res, start } = setup(); + start(); + await Promise.resolve(); + expect(remote.createWriteStream).toHaveBeenCalledOnce(); + res.emit("close"); + expect(remote.stream.destroyed).toBe(true); + remote.stream.emit("finish"); + expect(res.json).not.toHaveBeenCalled(); + }); + + it("does not open a remote file if cancelled while waiting for SFTP", async () => { + const remote = sftp(); + let connect!: (value: typeof remote) => void; + getSessionSftp.mockReturnValue( + new Promise((resolve) => { + connect = resolve; + }), + ); + const { res, start } = setup(); + start(); + res.emit("close"); + connect(remote); + await Promise.resolve(); + expect(remote.createWriteStream).not.toHaveBeenCalled(); + expect(res.json).not.toHaveBeenCalled(); + remote.stream.destroy(); + }); + + it("does not cancel a successfully completed response", async () => { + const remote = sftp(); + getSessionSftp.mockResolvedValue(remote); + const { res, start } = setup(); + start(); + await Promise.resolve(); + remote.stream.emit("finish"); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ complete: true }), + ); + res.writableFinished = true; + res.emit("close"); + expect(remote.stream.destroyed).toBe(false); + remote.stream.destroy(); + }); +}); + +describe("multipart upload disconnects", () => { + it("keeps normal request completion alive but cancels unfinished SSH writes on response disconnect", async () => { + const remote = { + ...sftp(), + unlink: vi.fn((_path, callback) => callback(null)), + }; + getSessionSftp.mockResolvedValue(remote); + const { req, res, start } = setup("/uploadFileStream"); + start(); + req.end( + [ + "--test-boundary", + 'Content-Disposition: form-data; name="sessionId"', + "", + "s", + "--test-boundary", + 'Content-Disposition: form-data; name="path"', + "", + "/", + "--test-boundary", + 'Content-Disposition: form-data; name="file"; filename="a.txt"', + "Content-Type: text/plain", + "", + "data", + "--test-boundary--", + "", + ].join("\r\n"), + ); + await new Promise((resolve) => setImmediate(resolve)); + expect(remote.createWriteStream).toHaveBeenCalledWith("/a.txt"); + req.emit("close"); + expect(remote.stream.destroyed).toBe(false); + res.emit("close"); + expect(remote.stream.destroyed).toBe(true); + expect(remote.unlink).toHaveBeenCalledWith("/a.txt", expect.any(Function)); + expect(res.json).not.toHaveBeenCalled(); + }); +}); diff --git a/plugins/file-manager/tests/frontend/api/ssh-file-operations-api.test.ts b/plugins/file-manager/tests/frontend/api/ssh-file-operations-api.test.ts index 356d7cdf7..307e5d78d 100644 --- a/plugins/file-manager/tests/frontend/api/ssh-file-operations-api.test.ts +++ b/plugins/file-manager/tests/frontend/api/ssh-file-operations-api.test.ts @@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const fileManagerApiMock = vi.hoisted(() => ({ post: vi.fn(async () => ({ data: { complete: false } })), + postForm: vi.fn(async () => ({ data: {} })), })); vi.mock("../../../src/frontend/api/client", () => ({ @@ -36,6 +37,7 @@ import { uploadSSHFile } from "../../../src/frontend/api/ssh-file-operations-api describe("chunked SSH file uploads", () => { beforeEach(() => { fileManagerApiMock.post.mockClear(); + fileManagerApiMock.postForm.mockClear(); }); it("sends raw chunks with the byte offset expected by the server", async () => { @@ -62,6 +64,7 @@ describe("chunked SSH file uploads", () => { }, headers: { "Content-Type": "application/octet-stream" }, timeout: 0, + signal: undefined, }, ); expect(fileManagerApiMock.post).toHaveBeenLastCalledWith( @@ -73,3 +76,71 @@ describe("chunked SSH file uploads", () => { ); }); }); + +describe("upload cancellation", () => { + it("passes the signal to multipart uploads", async () => { + const controller = new AbortController(); + await uploadSSHFile( + "s", + "/", + "a.txt", + new File(["data"], "a.txt"), + undefined, + undefined, + undefined, + controller.signal, + ); + expect(fileManagerApiMock.postForm).toHaveBeenLastCalledWith( + "/uploadFileStream", + expect.any(FormData), + expect.objectContaining({ signal: controller.signal }), + ); + }); + + it("does not start an already cancelled upload", async () => { + const controller = new AbortController(); + controller.abort(); + fileManagerApiMock.postForm.mockClear(); + await expect( + uploadSSHFile( + "s", + "/", + "a.txt", + new File(["data"], "a.txt"), + undefined, + undefined, + undefined, + controller.signal, + ), + ).rejects.toMatchObject({ name: "AbortError" }); + expect(fileManagerApiMock.postForm).not.toHaveBeenCalled(); + }); + + it("stops scheduling chunks after cancellation", async () => { + const controller = new AbortController(); + fileManagerApiMock.post.mockClear(); + const file = { + size: 2 * 1024 ** 3, + slice: vi.fn(() => new Blob(["chunk"])), + } as unknown as File; + await expect( + uploadSSHFile( + "s", + "/", + "large.bin", + file, + undefined, + undefined, + () => controller.abort(), + controller.signal, + ), + ).rejects.toMatchObject({ name: "AbortError" }); + expect(fileManagerApiMock.post).toHaveBeenCalledOnce(); + expect(fileManagerApiMock.post).toHaveBeenCalledWith( + "/uploadFileChunk", + expect.any(Blob), + expect.objectContaining({ signal: controller.signal }), + ); + expect(file.slice).toHaveBeenCalledOnce(); + }); +}); diff --git a/scripts/postcss-rem.test.ts b/scripts/postcss-rem.test.ts index e4eb0bb48..558e07307 100644 --- a/scripts/postcss-rem.test.ts +++ b/scripts/postcss-rem.test.ts @@ -1,21 +1,24 @@ import { createRequire } from "node:module"; -import postcss from "postcss"; +import postcss, { type AcceptedPlugin } from "postcss"; import { describe, expect, it } from "vitest"; const require = createRequire(import.meta.url); const config = require("../postcss.config.cjs") as { - plugins: Record; + plugins: AcceptedPlugin[]; }; -const pxtorem = require("postcss-pxtorem"); async function convert(css: string, from = "src/ui/index.css") { - const result = await postcss([ - pxtorem(config.plugins["postcss-pxtorem"]), - ]).process(css, { from }); + const result = await postcss(config.plugins).process(css, { from }); return result.css; } describe("interface size conversion", () => { + it("preserves circular radii emitted in exponent notation", async () => { + expect(await convert(".a{border-radius:3.40282e38px}")).toBe( + ".a{border-radius:calc(infinity * 1px)}", + ); + }); + it("turns px into rem against the 14px Normal root", async () => { expect(await convert(".a{font-size:10px;width:280px}")).toBe( ".a{font-size:0.71429rem;width:20rem}",