From 53601baed50dd71120ce92b06fa63aa2de2f7e4f Mon Sep 17 00:00:00 2001 From: Zxilly Date: Sat, 13 Jul 2024 18:51:26 +0800 Subject: [PATCH] feat: run analyze in webworker Signed-off-by: Zxilly --- ui/package.json | 2 +- ui/pnpm-lock.yaml | 25 +++++-- ui/src/Treemap.test.tsx | 9 +-- ui/src/explorer/Explorer.test.tsx | 45 ++----------- ui/src/explorer/Explorer.tsx | 89 +++++++++---------------- ui/src/explorer/Explorer.wasm.test.tsx | 17 +---- ui/src/{explorer => runtime}/fs.d.ts | 0 ui/src/{explorer => runtime}/fs.js | 0 ui/src/{tool => runtime}/wasm_exec.d.ts | 0 ui/src/{tool => runtime}/wasm_exec.js | 0 ui/src/testhelper.ts | 15 +++++ ui/src/tool/entry.test.ts | 13 +--- ui/src/vite-env.d.ts | 1 - ui/src/worker/__mocks__/helper.ts | 21 ++++++ ui/src/worker/event.ts | 17 +++++ ui/src/worker/helper.test.ts | 18 +++++ ui/src/worker/helper.ts | 62 +++++++++++++++++ ui/src/worker/worker.ts | 50 ++++++++++++++ ui/tsconfig.json | 6 +- ui/vitest.config.ts | 3 +- 20 files changed, 254 insertions(+), 139 deletions(-) rename ui/src/{explorer => runtime}/fs.d.ts (100%) rename ui/src/{explorer => runtime}/fs.js (100%) rename ui/src/{tool => runtime}/wasm_exec.d.ts (100%) rename ui/src/{tool => runtime}/wasm_exec.js (100%) create mode 100644 ui/src/testhelper.ts delete mode 100644 ui/src/vite-env.d.ts create mode 100644 ui/src/worker/__mocks__/helper.ts create mode 100644 ui/src/worker/event.ts create mode 100644 ui/src/worker/helper.test.ts create mode 100644 ui/src/worker/helper.ts create mode 100644 ui/src/worker/worker.ts diff --git a/ui/package.json b/ui/package.json index 6808c999d0..bfd268622b 100644 --- a/ui/package.json +++ b/ui/package.json @@ -43,11 +43,11 @@ "@types/react-dom": "^18.3.0", "@vitejs/plugin-react-swc": "^3.7.0", "@vitest/coverage-istanbul": "^2.0.2", + "@vitest/web-worker": "^2.0.2", "eslint": "^9.6.0", "eslint-plugin-import-x": "^3.0.0", "eslint-plugin-react-hooks": "5.1.0-rc.0", "eslint-plugin-react-refresh": "^0.4.8", - "happy-dom": "^14.12.3", "junit": "^1.4.9", "sass": "^1.77.8", "terser": "^5.31.2", diff --git a/ui/pnpm-lock.yaml b/ui/pnpm-lock.yaml index 366ea14a1b..870e0be629 100644 --- a/ui/pnpm-lock.yaml +++ b/ui/pnpm-lock.yaml @@ -90,6 +90,9 @@ importers: '@vitest/coverage-istanbul': specifier: ^2.0.2 version: 2.0.2(vitest@2.0.2(@types/node@20.14.10)(happy-dom@14.12.3)(jsdom@24.1.0)(lightningcss@1.25.1)(sass@1.77.8)(terser@5.31.2)) + '@vitest/web-worker': + specifier: ^2.0.2 + version: 2.0.2(vitest@2.0.2(@types/node@20.14.10)(happy-dom@14.12.3)(jsdom@24.1.0)(lightningcss@1.25.1)(sass@1.77.8)(terser@5.31.2)) eslint: specifier: ^9.6.0 version: 9.6.0 @@ -102,9 +105,6 @@ importers: eslint-plugin-react-refresh: specifier: ^0.4.8 version: 0.4.8(eslint@9.6.0) - happy-dom: - specifier: ^14.12.3 - version: 14.12.3 junit: specifier: ^1.4.9 version: 1.4.9 @@ -1389,6 +1389,11 @@ packages: '@vitest/utils@2.0.2': resolution: {integrity: sha512-pxCY1v7kmOCWYWjzc0zfjGTA3Wmn8PKnlPvSrsA643P1NHl1fOyXj2Q9SaNlrlFE+ivCsxM80Ov3AR82RmHCWQ==} + '@vitest/web-worker@2.0.2': + resolution: {integrity: sha512-mwnTvkxI0QHkWeTI9QgKHBk6c5CGFh5MDVlmWAZUCEWfqJvj393eLfz+YFK09cIPlCiGG/+IIjI83+UV3Qf80w==} + peerDependencies: + vitest: 2.0.2 + '@vue/compiler-core@3.4.31': resolution: {integrity: sha512-skOiodXWTV3DxfDhB4rOf3OGalpITLlgCeOwb+Y9GJpfQ8ErigdBUHomBzvG78JoVE8MJoQsb+qhZiHfKeNeEg==} @@ -5300,6 +5305,13 @@ snapshots: loupe: 3.1.1 tinyrainbow: 1.2.0 + '@vitest/web-worker@2.0.2(vitest@2.0.2(@types/node@20.14.10)(happy-dom@14.12.3)(jsdom@24.1.0)(lightningcss@1.25.1)(sass@1.77.8)(terser@5.31.2))': + dependencies: + debug: 4.3.5 + vitest: 2.0.2(@types/node@20.14.10)(happy-dom@14.12.3)(jsdom@24.1.0)(lightningcss@1.25.1)(sass@1.77.8)(terser@5.31.2) + transitivePeerDependencies: + - supports-color + '@vue/compiler-core@3.4.31': dependencies: '@babel/parser': 7.24.8 @@ -6524,6 +6536,7 @@ snapshots: entities: 4.5.0 webidl-conversions: 7.0.0 whatwg-mimetype: 3.0.0 + optional: true has-flag@3.0.0: {} @@ -7912,7 +7925,8 @@ snapshots: webidl-conversions@3.0.1: {} - webidl-conversions@7.0.0: {} + webidl-conversions@7.0.0: + optional: true webpack-sources@3.2.3: {} @@ -7925,7 +7939,8 @@ snapshots: whatwg-fetch@3.6.20: {} - whatwg-mimetype@3.0.0: {} + whatwg-mimetype@3.0.0: + optional: true whatwg-mimetype@4.0.0: optional: true diff --git a/ui/src/Treemap.test.tsx b/ui/src/Treemap.test.tsx index 98f5855246..98494b1fe7 100644 --- a/ui/src/Treemap.test.tsx +++ b/ui/src/Treemap.test.tsx @@ -1,16 +1,11 @@ -import { readFileSync } from "node:fs"; -import path from "node:path"; import { expect, it } from "vitest"; import { render } from "@testing-library/react"; -import { parseResult } from "./generated/schema.ts"; import { createEntry } from "./tool/entry.ts"; import TreeMap from "./TreeMap.tsx"; +import { getTestResult } from "./testhelper.ts"; it("treemap", () => { - const data = readFileSync(path.join(__dirname, "..", "..", "testdata", "result.json")).toString(); - - const r = parseResult(data); - expect(r).toBeDefined(); + const r = getTestResult(); const e = createEntry(r); expect(e).toMatchSnapshot(); diff --git a/ui/src/explorer/Explorer.test.tsx b/ui/src/explorer/Explorer.test.tsx index 455be0d21e..6d86154c76 100644 --- a/ui/src/explorer/Explorer.test.tsx +++ b/ui/src/explorer/Explorer.test.tsx @@ -1,47 +1,16 @@ -import { readFileSync } from "node:fs"; -import path from "node:path"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; -import { parseResult } from "../generated/schema.ts"; import { Explorer } from "./Explorer"; -const result = parseResult( - readFileSync( - path.join(__dirname, "..", "..", "..", "testdata", "result.json"), - ).toString(), -); - -vi.mock("../../gsa.wasm?init", () => { - return { - default: async () => { - return Promise.resolve({}); - }, - }; -}); +vi.mock("../worker/helper.ts"); describe("explorer", () => { - beforeEach(() => { - vi.stubGlobal("Go", class { - importObject = {}; - run = vi.fn(() => Promise.resolve()); - - constructor() { - } - }); - }); - afterEach(() => { - vi.clearAllMocks(); + vi.restoreAllMocks(); cleanup(); }); describe("wasm success", () => { - beforeEach(() => { - vi.stubGlobal("gsa_analyze", () => { - return result; - }); - }); - it("should display loading state initially", () => { render(); expect(screen.getByText("Loading WebAssembly module...")).toBeInTheDocument(); @@ -62,16 +31,12 @@ describe("explorer", () => { }); it("should display error when analysis fails", async () => { - vi.stubGlobal("gsa_analyze", () => { - return null; - }); - render(); await waitFor(() => screen.getByText("Select a go binary")); - fireEvent.change(screen.getByTestId("file-selector"), { target: { files: [new File(["test"], "test.bin")] } }); - await waitFor(() => screen.getByText("Failed to analyze test.bin")); + fireEvent.change(screen.getByTestId("file-selector"), { target: { files: [new File(["test"], "fail")] } }); + await waitFor(() => screen.getByText("Failed to analyze fail")); }); }); }); diff --git a/ui/src/explorer/Explorer.tsx b/ui/src/explorer/Explorer.tsx index 342a28f12d..7b1ad95b04 100644 --- a/ui/src/explorer/Explorer.tsx +++ b/ui/src/explorer/Explorer.tsx @@ -2,15 +2,11 @@ import type { ReactNode } from "react"; import React, { useEffect, useMemo } from "react"; import { useAsync } from "react-use"; import { Box, Dialog, DialogContent, DialogContentText, DialogTitle } from "@mui/material"; -import gsa from "../../gsa.wasm?init"; import { createEntry } from "../tool/entry.ts"; import TreeMap from "../TreeMap.tsx"; +import { GsaInstance } from "../worker/helper.ts"; import { FileSelector } from "./FileSelector.tsx"; -import { resetCallback, setCallback } from "./fs.js"; - -import "../tool/wasm_exec.js"; - type ModalState = { isOpen: false; } | { @@ -19,55 +15,46 @@ type ModalState = { content: ReactNode; }; -declare function gsa_analyze(name: string, data: Uint8Array): import("../generated/schema.ts").Result; +const LogViewer: React.FC<{ log: string }> = ({ log }) => { + return ( + + {log} + + ); +}; export const Explorer: React.FC = () => { - const go = useMemo(() => new Go(), []); + const [log, setLog] = React.useState(""); - const { value: inst, loading, error: loadError } = useAsync(async () => { - return await gsa(go.importObject); + const { value: analyzer, loading, error: loadError } = useAsync(async () => { + return GsaInstance.create((line) => { + setLog(prev => `${prev + line}\n`); + }); }); - useAsync(async () => { - if (loading || loadError || inst === undefined) { - return; - } - - return await go.run(inst); - }, [inst]); - const [file, setFile] = React.useState(null); const [modalState, setModalState] = React.useState({ isOpen: false }); const { value: result, loading: analyzing } = useAsync(async () => { - if (!file) { + if (!file || !analyzer) { return; } const bytes = await file.arrayBuffer(); const uint8 = new Uint8Array(bytes); - return gsa_analyze(file.name, uint8); + return analyzer.analyze(file.name, uint8); }, [file]); - const [log, setLog] = React.useState(""); - - const friendlyLog = useMemo(() => { - if (log === "") { - return "Waiting for log"; - } - return log; - }, [log]); - - useEffect(() => { - setCallback((line) => { - setLog(log => `${log + line}\n`); - }); - - return resetCallback; - }, []); - const entry = useMemo(() => { if (!result) { return null; @@ -77,12 +64,17 @@ export const Explorer: React.FC = () => { }, [result]); useEffect(() => { - if (loadError) { + if (loadError || (!analyzer && !loading)) { setModalState({ isOpen: true, title: "Error", content: - {loadError.message}, + <> + + Failed to load WebAssembly module + + {loadError && {loadError.message}} + , }); } else if (loading) { @@ -93,17 +85,6 @@ export const Explorer: React.FC = () => { Loading WebAssembly module..., }); } - else if (!inst) { - setModalState({ - isOpen: true, - title: "Error", - content: ( - - Failed to load WebAssembly module - - ), - }); - } else if (!file) { setModalState({ isOpen: true, @@ -121,9 +102,7 @@ export const Explorer: React.FC = () => { isOpen: true, title: `Analyzing ${file.name}`, content: ( - - {friendlyLog} - + ), }); } @@ -132,16 +111,14 @@ export const Explorer: React.FC = () => { isOpen: true, title: `Failed to analyze ${file.name}`, content: ( - - {friendlyLog} - + ), }); } else { setModalState({ isOpen: false }); } - }, [loadError, loading, file, result, analyzing, inst, entry, friendlyLog]); + }, [loadError, loading, file, result, analyzing, entry, analyzer, log]); return ( <> diff --git a/ui/src/explorer/Explorer.wasm.test.tsx b/ui/src/explorer/Explorer.wasm.test.tsx index 4f08938a49..768d630082 100644 --- a/ui/src/explorer/Explorer.wasm.test.tsx +++ b/ui/src/explorer/Explorer.wasm.test.tsx @@ -1,22 +1,7 @@ -import { it, vi } from "vitest"; +import { it } from "vitest"; import { render, screen, waitFor } from "@testing-library/react"; import { Explorer } from "./Explorer.tsx"; -vi.mock("../../gsa.wasm?init", () => { - return { - default: async () => { - return Promise.resolve(undefined); - }, - }; -}); - -vi.stubGlobal("Go", class { - importObject = {}; - run = vi.fn(() => Promise.resolve()); - constructor() { - } -}); - it("explorer should display error when loading fails", async () => { render(); await waitFor(() => screen.getByText("Failed to load WebAssembly module")); diff --git a/ui/src/explorer/fs.d.ts b/ui/src/runtime/fs.d.ts similarity index 100% rename from ui/src/explorer/fs.d.ts rename to ui/src/runtime/fs.d.ts diff --git a/ui/src/explorer/fs.js b/ui/src/runtime/fs.js similarity index 100% rename from ui/src/explorer/fs.js rename to ui/src/runtime/fs.js diff --git a/ui/src/tool/wasm_exec.d.ts b/ui/src/runtime/wasm_exec.d.ts similarity index 100% rename from ui/src/tool/wasm_exec.d.ts rename to ui/src/runtime/wasm_exec.d.ts diff --git a/ui/src/tool/wasm_exec.js b/ui/src/runtime/wasm_exec.js similarity index 100% rename from ui/src/tool/wasm_exec.js rename to ui/src/runtime/wasm_exec.js diff --git a/ui/src/testhelper.ts b/ui/src/testhelper.ts new file mode 100644 index 0000000000..1ab57b2ca8 --- /dev/null +++ b/ui/src/testhelper.ts @@ -0,0 +1,15 @@ +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { assert } from "vitest"; +import { parseResult } from "./generated/schema.ts"; + +export function getTestResult() { + const data = readFileSync( + path.join(__dirname, "..", "..", "testdata", "result.json"), + ).toString(); + + const r = parseResult(data); + assert.isNotNull(r); + + return r; +} diff --git a/ui/src/tool/entry.test.ts b/ui/src/tool/entry.test.ts index 2820855ab7..29b41276c8 100644 --- a/ui/src/tool/entry.test.ts +++ b/ui/src/tool/entry.test.ts @@ -1,7 +1,5 @@ -import { readFileSync } from "node:fs"; -import path from "node:path"; -import { assert, describe, expect, expectTypeOf, it } from "vitest"; -import { parseResult } from "../generated/schema.ts"; +import { describe, expect, expectTypeOf, it } from "vitest"; +import { getTestResult } from "../testhelper.ts"; import type { EntryChildren, EntryLike, EntryType } from "./entry.ts"; import { BaseImpl, DisasmImpl, UnknownImpl, createEntry } from "./entry.ts"; @@ -11,12 +9,7 @@ describe("entry", () => { }); it("match", () => { - const data = readFileSync( - path.join(__dirname, "..", "..", "..", "testdata", "result.json"), - ).toString(); - - const r = parseResult(data); - assert.isNotNull(r); + const r = getTestResult(); const e = createEntry(r); diff --git a/ui/src/vite-env.d.ts b/ui/src/vite-env.d.ts deleted file mode 100644 index 11f02fe2a0..0000000000 --- a/ui/src/vite-env.d.ts +++ /dev/null @@ -1 +0,0 @@ -/// diff --git a/ui/src/worker/__mocks__/helper.ts b/ui/src/worker/__mocks__/helper.ts new file mode 100644 index 0000000000..bb26f40754 --- /dev/null +++ b/ui/src/worker/__mocks__/helper.ts @@ -0,0 +1,21 @@ +import type { Result } from "../../generated/schema.ts"; +import { getTestResult } from "../../testhelper.ts"; + +export class GsaInstance { + private constructor(_worker: any, _log: any) { + } + + static async create(_log: (line: string) => void): Promise { + return new GsaInstance({}, {}); + } + + async analyze(filename: string, _data: Uint8Array): Promise { + if (filename === "fail") { + return null; + } + + await new Promise(resolve => setTimeout(resolve, 100)); + + return getTestResult(); + } +} diff --git a/ui/src/worker/event.ts b/ui/src/worker/event.ts new file mode 100644 index 0000000000..17772585c9 --- /dev/null +++ b/ui/src/worker/event.ts @@ -0,0 +1,17 @@ +export interface LoadEvent { + type: "load"; + status: "success" | "error"; + reason?: string; +} + +export interface AnalyzeEvent { + type: "analyze"; + result: import("../generated/schema.ts").Result | null; +} + +export interface LogEvent { + type: "log"; + line: string; +} + +export type WasmEvent = LoadEvent | AnalyzeEvent | LogEvent; diff --git a/ui/src/worker/helper.test.ts b/ui/src/worker/helper.test.ts new file mode 100644 index 0000000000..97a1afd379 --- /dev/null +++ b/ui/src/worker/helper.test.ts @@ -0,0 +1,18 @@ +import "@vitest/web-worker"; +import { describe, expect, it, vi } from "vitest"; +import { GsaInstance } from "./helper.ts"; + +describe("worker helper", () => { + it.skip("instance", async () => { + const logHandler = vi.fn(); + + const inst = await GsaInstance.create(logHandler); + + const data = new Uint8Array([1, 2, 3, 4]); + + const result = await inst.analyze("test.bin", data); + + expect(logHandler).toHaveBeenCalled(); + expect(result).toBeNull(); + }); +}); diff --git a/ui/src/worker/helper.ts b/ui/src/worker/helper.ts new file mode 100644 index 0000000000..d771ae3318 --- /dev/null +++ b/ui/src/worker/helper.ts @@ -0,0 +1,62 @@ +import type { Result } from "../generated/schema.ts"; +import type { LoadEvent, WasmEvent } from "./event.ts"; +import worker from "./worker.ts?worker&url"; + +export class GsaInstance { + logHandler: (line: string) => void; + worker: Worker; + + private constructor(worker: Worker, log: (line: string) => void) { + this.worker = worker; + this.logHandler = log; + } + + static async create(log: (line: string) => void): Promise { + const ret = new GsaInstance( + new Worker(worker, { + type: "module", + }), + log, + ); + + return new Promise((resolve, reject) => { + const loadCb = (e: MessageEvent) => { + const data = e.data; + + if (data.type !== "load") { + return reject(new Error("Unexpected message type")); + } + + ret.worker.removeEventListener("message", loadCb); + if (data.status === "success") { + resolve(ret); + } + else { + reject(new Error(data.reason)); + } + }; + + ret.worker.addEventListener("message", loadCb); + }); + } + + async analyze(filename: string, data: Uint8Array): Promise { + return new Promise((resolve) => { + const analyzeCb = (e: MessageEvent) => { + const data = e.data; + + switch (data.type) { + case "log": + this.logHandler(data.line); + break; + case "analyze": + this.worker.removeEventListener("message", analyzeCb); + resolve(data.result); + } + }; + + this.worker.addEventListener("message", analyzeCb); + this.worker.postMessage([filename, data], [data.buffer]); + }); + } +} diff --git a/ui/src/worker/worker.ts b/ui/src/worker/worker.ts new file mode 100644 index 0000000000..6841a45461 --- /dev/null +++ b/ui/src/worker/worker.ts @@ -0,0 +1,50 @@ +import "../runtime/wasm_exec.js"; +import { setCallback } from "../runtime/fs"; +import gsa from "../../gsa.wasm?url"; +import type { AnalyzeEvent, LoadEvent, LogEvent } from "./event.ts"; + +declare const self: DedicatedWorkerGlobalScope; +declare function gsa_analyze(name: string, data: Uint8Array): import("../generated/schema.ts").Result; + +async function init() { + const go = new Go(); + + const inst = (await WebAssembly.instantiateStreaming( + fetch(gsa), + go.importObject, + )).instance; + + go.run(inst).then(() => { + console.error("Go exited"); + }); +} + +init().then(() => { + self.postMessage({ + status: "success", + type: "load", + } satisfies LoadEvent); + + setCallback((line) => { + self.postMessage({ + type: "log", + line, + } satisfies LogEvent); + }); +}).catch((e: Error) => { + self.postMessage({ + status: "error", + type: "load", + reason: e.message, + } satisfies LoadEvent); +}); + +self.onmessage = (e: MessageEvent<[string, Uint8Array]>) => { + const [filename, data] = e.data; + const result = gsa_analyze(filename, data); + + self.postMessage({ + result, + type: "analyze", + } satisfies AnalyzeEvent); +}; diff --git a/ui/tsconfig.json b/ui/tsconfig.json index 155e0f7d7c..36e4554e2c 100644 --- a/ui/tsconfig.json +++ b/ui/tsconfig.json @@ -5,7 +5,8 @@ "lib": [ "ES2020", "DOM", - "DOM.Iterable" + "DOM.Iterable", + "WebWorker" ], "useDefineForClassFields": true, "module": "ESNext", @@ -14,7 +15,8 @@ "resolveJsonModule": true, "types": [ "@testing-library/jest-dom", - "@types/golang-wasm-exec" + "@types/golang-wasm-exec", + "vite/client" ], "allowImportingTsExtensions": true, /* Linting */ diff --git a/ui/vitest.config.ts b/ui/vitest.config.ts index ee6785bbae..bda45c566b 100644 --- a/ui/vitest.config.ts +++ b/ui/vitest.config.ts @@ -7,7 +7,7 @@ export default defineConfig({ react(), ], test: { - environment: "happy-dom", + environment: "jsdom", setupFiles: ["./vitest.setup.ts"], coverage: { provider: "istanbul", @@ -16,6 +16,7 @@ export default defineConfig({ "src/tool/wasm_exec.js", "src/schema/schema.ts", "src/generated/schema.ts", + "src/testhelper.ts", "vite.*.ts", ...coverageConfigDefaults.exclude, ],