Advance M7 workflows and release operations
This commit is contained in:
@@ -8,6 +8,7 @@ import type {
|
||||
WebEngineRequest,
|
||||
WebEngineResponse,
|
||||
WebEngineResult,
|
||||
WebEngineOpenResourceStatus,
|
||||
WebEngineStatus,
|
||||
} from "../../../protocol/web-engine";
|
||||
import type { LODGenerationRequest } from "../../../protocol/lod";
|
||||
@@ -25,6 +26,7 @@ interface PendingRequest {
|
||||
reject: (error: ErrorReport) => void;
|
||||
onProgress?: (progress: ProgressEvent) => void;
|
||||
timer: ReturnType<typeof setTimeout>;
|
||||
cleanup?: () => void;
|
||||
}
|
||||
|
||||
export interface WebEngineClientOptions {
|
||||
@@ -61,11 +63,11 @@ export class WebEngineClient {
|
||||
return (await this.request({ type: "init" })).status;
|
||||
}
|
||||
|
||||
async openBlend(buffer: ArrayBuffer, onProgress?: (progress: ProgressEvent) => void): Promise<BlendOpenResult> {
|
||||
async openBlend(buffer: ArrayBuffer, onProgress?: (progress: ProgressEvent) => void, signal?: AbortSignal): Promise<BlendOpenResult> {
|
||||
if (buffer.byteLength === 0) {
|
||||
throw this.report("INVALID_ARGUMENT", "无法打开空的 .blend 文件", true);
|
||||
}
|
||||
const result = await this.request({ type: "openBlend", buffer }, [buffer], onProgress);
|
||||
const result = await this.request({ type: "openBlend", buffer }, [buffer], onProgress, signal);
|
||||
if (!result.snapshot) throw this.report("BLEND_READ_FAILED", "WebEngine 未返回 SceneIR", true);
|
||||
this.geometryBuffers = result.geometryBuffers ?? [];
|
||||
this.nonMeshGeometryBuffers = result.nonMeshGeometryBuffers ?? [];
|
||||
@@ -146,6 +148,12 @@ export class WebEngineClient {
|
||||
return result.blend;
|
||||
}
|
||||
|
||||
async openResourceStatus(): Promise<WebEngineOpenResourceStatus> {
|
||||
const result = await this.request({ type: "openResourceStatus" });
|
||||
if (!result.openResources) throw this.report("INVALID_ARGUMENT", "WebEngine open resource status missing", true);
|
||||
return result.openResources;
|
||||
}
|
||||
|
||||
terminate(): void {
|
||||
this.failPending(this.report("WORKER_TERMINATED", "WebEngineWorker 已关闭", true));
|
||||
this.worker?.terminate();
|
||||
@@ -169,16 +177,29 @@ export class WebEngineClient {
|
||||
command: WebEngineRequest["command"],
|
||||
transfer: Transferable[] = [],
|
||||
onProgress?: (progress: ProgressEvent) => void,
|
||||
signal?: AbortSignal,
|
||||
): Promise<WebEngineResult> {
|
||||
const worker = this.start();
|
||||
const requestId = `web-engine-${++this.requestCounter}`;
|
||||
const request = { requestId, command } as WebEngineRequest;
|
||||
return new Promise<WebEngineResult>((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
this.pending.get(requestId)?.cleanup?.();
|
||||
this.pending.delete(requestId);
|
||||
reject(this.report("WORKER_TERMINATED", `WebEngine 请求超时: ${command.type}`, true));
|
||||
}, this.timeoutMs);
|
||||
this.pending.set(requestId, { resolve, reject, onProgress, timer });
|
||||
const abort = (): void => {
|
||||
worker.postMessage({
|
||||
requestId: `web-engine-cancel-${++this.requestCounter}`,
|
||||
command: { type: "cancelOpen", targetRequestId: requestId },
|
||||
} satisfies WebEngineRequest);
|
||||
};
|
||||
const cleanup = signal ? () => signal.removeEventListener("abort", abort) : undefined;
|
||||
this.pending.set(requestId, { resolve, reject, onProgress, timer, cleanup });
|
||||
if (signal) {
|
||||
signal.addEventListener("abort", abort, { once: true });
|
||||
if (signal.aborted) abort();
|
||||
}
|
||||
worker.postMessage(request, transfer);
|
||||
});
|
||||
}
|
||||
@@ -192,6 +213,7 @@ export class WebEngineClient {
|
||||
}
|
||||
this.pending.delete(response.requestId);
|
||||
clearTimeout(pending.timer);
|
||||
pending.cleanup?.();
|
||||
if (response.ok) pending.resolve(response.result);
|
||||
else pending.reject(response.error);
|
||||
}
|
||||
@@ -199,6 +221,7 @@ export class WebEngineClient {
|
||||
private failPending(error: ErrorReport): void {
|
||||
for (const pending of this.pending.values()) {
|
||||
clearTimeout(pending.timer);
|
||||
pending.cleanup?.();
|
||||
pending.reject(error);
|
||||
}
|
||||
this.pending.clear();
|
||||
|
||||
139
web/app/src/engine-client/browser-engine-variant-session.ts
Normal file
139
web/app/src/engine-client/browser-engine-variant-session.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import type { WebEngineVariantV2 } from "../../../protocol/manifest";
|
||||
import type {
|
||||
EngineVariantResourceState,
|
||||
EngineVariantSession,
|
||||
} from "./engine-variant-bootstrap";
|
||||
import { EngineVariantLoadError } from "./engine-variant-bootstrap";
|
||||
|
||||
interface EmscriptenPThreadRuntime {
|
||||
unusedWorkers: Worker[];
|
||||
runningWorkers: Worker[];
|
||||
terminateAllThreads(): void;
|
||||
}
|
||||
|
||||
interface BrowserVariantModule {
|
||||
HEAPU8: Uint8Array;
|
||||
_malloc(size: number): number;
|
||||
_free(pointer: number): void;
|
||||
_web_engine_create(): number;
|
||||
_web_engine_destroy(handle: number): void;
|
||||
_web_engine_get_live_handles(): number;
|
||||
_web_engine_open_blend(handle: number, data: number, length: number): number;
|
||||
PThread?: EmscriptenPThreadRuntime;
|
||||
}
|
||||
|
||||
type BrowserVariantFactory = (options: { wasmBinary: ArrayBuffer }) => Promise<BrowserVariantModule>;
|
||||
|
||||
async function verifiedBytes(url: string, expectedSha256: string, controllers: Set<AbortController>): Promise<ArrayBuffer> {
|
||||
const controller = new AbortController();
|
||||
controllers.add(controller);
|
||||
try {
|
||||
const response = await fetch(url, { cache: "no-store", signal: controller.signal });
|
||||
if (!response.ok) {
|
||||
throw new EngineVariantLoadError(
|
||||
"ENGINE_VARIANT_RESOURCE_REQUEST_FAILED",
|
||||
`${url} returned ${response.status}`,
|
||||
);
|
||||
}
|
||||
const bytes = await response.arrayBuffer();
|
||||
const digest = await crypto.subtle.digest("SHA-256", bytes);
|
||||
const actual = [...new Uint8Array(digest)]
|
||||
.map((byte) => byte.toString(16).padStart(2, "0"))
|
||||
.join("");
|
||||
if (actual !== expectedSha256) {
|
||||
throw new EngineVariantLoadError("ENGINE_VARIANT_RESOURCE_HASH_MISMATCH", url);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
finally {
|
||||
controllers.delete(controller);
|
||||
}
|
||||
}
|
||||
|
||||
class BrowserEngineVariantSession implements EngineVariantSession<ArrayBuffer> {
|
||||
readonly variant: WebEngineVariantV2;
|
||||
private readonly module: BrowserVariantModule;
|
||||
private readonly pendingRequests: Set<AbortController>;
|
||||
private readonly timers = new Set<ReturnType<typeof setTimeout>>();
|
||||
private handle: number;
|
||||
|
||||
constructor(
|
||||
variant: WebEngineVariantV2,
|
||||
module: BrowserVariantModule,
|
||||
handle: number,
|
||||
pendingRequests: Set<AbortController>,
|
||||
) {
|
||||
this.variant = variant;
|
||||
this.module = module;
|
||||
this.handle = handle;
|
||||
this.pendingRequests = pendingRequests;
|
||||
}
|
||||
|
||||
async openProject(project: ArrayBuffer): Promise<void> {
|
||||
if (this.handle <= 0) throw new Error("ENGINE_VARIANT_SESSION_DISPOSED");
|
||||
const pointer = this.module._malloc(project.byteLength);
|
||||
if (project.byteLength > 0 && pointer <= 0) throw new Error("ENGINE_VARIANT_PROJECT_ALLOCATION_FAILED");
|
||||
try {
|
||||
if (project.byteLength > 0) this.module.HEAPU8.set(new Uint8Array(project), pointer);
|
||||
const result = this.module._web_engine_open_blend(this.handle, pointer, project.byteLength);
|
||||
if (result !== 0) throw new Error(`ENGINE_VARIANT_PROJECT_OPEN_FAILED: ${result}`);
|
||||
}
|
||||
finally {
|
||||
if (pointer > 0) this.module._free(pointer);
|
||||
}
|
||||
}
|
||||
|
||||
resourceState(): EngineVariantResourceState {
|
||||
const pthread = this.module.PThread;
|
||||
return {
|
||||
handles: this.handle > 0 ? this.module._web_engine_get_live_handles() : 0,
|
||||
workers: pthread ? pthread.unusedWorkers.length + pthread.runningWorkers.length : 0,
|
||||
timers: this.timers.size,
|
||||
pendingRequests: this.pendingRequests.size,
|
||||
};
|
||||
}
|
||||
|
||||
testOnlySeedTrackedResources(): void {
|
||||
this.pendingRequests.add(new AbortController());
|
||||
const timer = setTimeout(() => this.timers.delete(timer), 60_000);
|
||||
this.timers.add(timer);
|
||||
}
|
||||
|
||||
async dispose(): Promise<EngineVariantResourceState> {
|
||||
for (const controller of this.pendingRequests) controller.abort();
|
||||
this.pendingRequests.clear();
|
||||
for (const timer of this.timers) clearTimeout(timer);
|
||||
this.timers.clear();
|
||||
if (this.handle > 0) {
|
||||
this.module._web_engine_destroy(this.handle);
|
||||
this.handle = 0;
|
||||
}
|
||||
this.module.PThread?.terminateAllThreads();
|
||||
return this.resourceState();
|
||||
}
|
||||
}
|
||||
|
||||
export async function createBrowserEngineVariantSession(
|
||||
variant: WebEngineVariantV2,
|
||||
): Promise<EngineVariantSession<ArrayBuffer>> {
|
||||
const controllers = new Set<AbortController>();
|
||||
const verifiedResources = new Map<string, ArrayBuffer>();
|
||||
const verify = async (resource: { url: string; sha256: string }): Promise<ArrayBuffer> => {
|
||||
const existing = verifiedResources.get(resource.url);
|
||||
if (existing) return existing;
|
||||
const bytes = await verifiedBytes(resource.url, resource.sha256, controllers);
|
||||
verifiedResources.set(resource.url, bytes);
|
||||
return bytes;
|
||||
};
|
||||
await verify(variant.resources.js);
|
||||
const wasmBinary = await verify(variant.resources.wasm);
|
||||
if (variant.id === "pthread") await verify(variant.resources.pthreadWorker);
|
||||
const imported = await import(/* @vite-ignore */ variant.resources.js.url) as { default: BrowserVariantFactory };
|
||||
const module = await imported.default({ wasmBinary });
|
||||
const handle = module._web_engine_create();
|
||||
if (handle <= 0) {
|
||||
module.PThread?.terminateAllThreads();
|
||||
throw new Error("ENGINE_VARIANT_HANDLE_CREATION_FAILED");
|
||||
}
|
||||
return new BrowserEngineVariantSession(variant, module, handle, controllers);
|
||||
}
|
||||
195
web/app/src/engine-client/engine-variant-bootstrap.ts
Normal file
195
web/app/src/engine-client/engine-variant-bootstrap.ts
Normal file
@@ -0,0 +1,195 @@
|
||||
import {
|
||||
bindWebEngineRelease,
|
||||
selectWebEngineVariant,
|
||||
type WasmThreadingCapabilities,
|
||||
type WebEngineReleaseBinding,
|
||||
type WebEngineVariantPolicy,
|
||||
} from "../../../protocol/engine-variant";
|
||||
import type { CapabilityGateResult } from "../../../protocol/capability-gates";
|
||||
import type { WebEngineManifestV2, WebEngineVariantV2 } from "../../../protocol/manifest";
|
||||
|
||||
export interface EngineVariantResourceState {
|
||||
handles: number;
|
||||
workers: number;
|
||||
timers: number;
|
||||
pendingRequests: number;
|
||||
}
|
||||
|
||||
export interface EngineVariantSession<Project = ArrayBuffer> {
|
||||
readonly variant: WebEngineVariantV2;
|
||||
openProject(project: Project): Promise<void>;
|
||||
dispose(): Promise<EngineVariantResourceState>;
|
||||
resourceState(): EngineVariantResourceState;
|
||||
testOnlySeedTrackedResources?(): void;
|
||||
}
|
||||
|
||||
export interface EngineVariantFallbackReason {
|
||||
code: "PTHREAD_INITIALIZATION_FAILED";
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface EngineVariantBootstrapResult {
|
||||
status: "READY";
|
||||
selected: "single" | "pthread";
|
||||
attempted: Array<"single" | "pthread">;
|
||||
fallbackReason: EngineVariantFallbackReason | null;
|
||||
pthreadGate: CapabilityGateResult;
|
||||
openCount: number;
|
||||
failedAttemptCleanup: EngineVariantResourceState | null;
|
||||
}
|
||||
|
||||
export interface EngineVariantBootstrapOutcome<Project = ArrayBuffer> {
|
||||
result: EngineVariantBootstrapResult;
|
||||
session: EngineVariantSession<Project>;
|
||||
}
|
||||
|
||||
export type WebEngineRefreshRequired = Extract<WebEngineReleaseBinding, { status: "REFRESH_REQUIRED" }>;
|
||||
|
||||
export type WebEngineReleaseBootstrapOutcome<Project = ArrayBuffer> =
|
||||
| EngineVariantBootstrapOutcome<Project>
|
||||
| { result: WebEngineRefreshRequired; session: null };
|
||||
|
||||
export interface EngineVariantBootstrapDependencies<Project = ArrayBuffer> {
|
||||
initialize(variant: WebEngineVariantV2): Promise<EngineVariantSession<Project>>;
|
||||
}
|
||||
|
||||
export interface EngineVariantBootstrapTestHooks {
|
||||
failPthreadAfterInitialize?: boolean;
|
||||
seedTrackedPthreadResources?: boolean;
|
||||
}
|
||||
|
||||
export class EngineVariantLoadError extends Error {
|
||||
readonly code: "ENGINE_VARIANT_RESOURCE_REQUEST_FAILED" | "ENGINE_VARIANT_RESOURCE_HASH_MISMATCH";
|
||||
|
||||
constructor(code: EngineVariantLoadError["code"], message: string, options?: ErrorOptions) {
|
||||
super(message, options);
|
||||
this.name = "EngineVariantLoadError";
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
export class EngineVariantBootstrapError extends Error {
|
||||
readonly code: "ENGINE_VARIANT_INTEGRITY_FAILED";
|
||||
readonly attempted: Array<"single" | "pthread">;
|
||||
|
||||
constructor(attempted: Array<"single" | "pthread">, cause: EngineVariantLoadError) {
|
||||
super(`${cause.code}: ${cause.message}`, { cause });
|
||||
this.name = "EngineVariantBootstrapError";
|
||||
this.code = "ENGINE_VARIANT_INTEGRITY_FAILED";
|
||||
this.attempted = [...attempted];
|
||||
}
|
||||
}
|
||||
|
||||
function failureMessage(error: unknown): string {
|
||||
return error instanceof Error && error.message ? error.message : "pthread engine initialization failed";
|
||||
}
|
||||
|
||||
function rejectIntegrityFailure(
|
||||
error: unknown,
|
||||
attempted: Array<"single" | "pthread">,
|
||||
): void {
|
||||
if (error instanceof EngineVariantLoadError && error.code === "ENGINE_VARIANT_RESOURCE_HASH_MISMATCH") {
|
||||
throw new EngineVariantBootstrapError(attempted, error);
|
||||
}
|
||||
}
|
||||
|
||||
async function bootstrapSelectedWebEngineVariant<Project = ArrayBuffer>(
|
||||
manifest: WebEngineManifestV2,
|
||||
policy: WebEngineVariantPolicy,
|
||||
capabilities: WasmThreadingCapabilities,
|
||||
dependencies: EngineVariantBootstrapDependencies<Project>,
|
||||
pendingProject?: Project,
|
||||
testHooks?: EngineVariantBootstrapTestHooks,
|
||||
): Promise<EngineVariantBootstrapOutcome<Project>> {
|
||||
const selection = selectWebEngineVariant(manifest, policy, capabilities);
|
||||
if (!selection.selectedVariant) throw new Error("PTHREAD_REQUIRED_CAPABILITY_BLOCKED");
|
||||
|
||||
const attempted: Array<"single" | "pthread"> = [];
|
||||
let failedAttemptCleanup: EngineVariantResourceState | null = null;
|
||||
let fallbackReason: EngineVariantFallbackReason | null = null;
|
||||
let session: EngineVariantSession<Project> | null = null;
|
||||
|
||||
const initialize = async (variant: WebEngineVariantV2): Promise<EngineVariantSession<Project>> => {
|
||||
attempted.push(variant.id);
|
||||
return dependencies.initialize(variant);
|
||||
};
|
||||
|
||||
try {
|
||||
session = await initialize(selection.selectedVariant);
|
||||
if (selection.selectedVariant.id === "pthread") {
|
||||
if (testHooks?.seedTrackedPthreadResources) session.testOnlySeedTrackedResources?.();
|
||||
if (testHooks?.failPthreadAfterInitialize) throw new Error("TEST_PTHREAD_INITIALIZATION_FAILURE");
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
const failedSession = session;
|
||||
if (failedSession) failedAttemptCleanup = await failedSession.dispose();
|
||||
rejectIntegrityFailure(error, attempted);
|
||||
if (policy !== "AUTO" || selection.selectedVariant.id !== "pthread") throw error;
|
||||
fallbackReason = { code: "PTHREAD_INITIALIZATION_FAILED", message: failureMessage(error) };
|
||||
const single = manifest.variants.find((variant) => variant.id === "single");
|
||||
if (!single) throw new Error("ENGINE_MANIFEST_VARIANTS_MISSING", { cause: error });
|
||||
try {
|
||||
session = await initialize(single);
|
||||
}
|
||||
catch (fallbackError) {
|
||||
rejectIntegrityFailure(fallbackError, attempted);
|
||||
throw fallbackError;
|
||||
}
|
||||
}
|
||||
|
||||
if (!session) throw new Error("ENGINE_VARIANT_INITIALIZATION_FAILED");
|
||||
let openCount = 0;
|
||||
if (pendingProject !== undefined) {
|
||||
try {
|
||||
await session.openProject(pendingProject);
|
||||
openCount = 1;
|
||||
}
|
||||
catch (error) {
|
||||
await session.dispose();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
return {
|
||||
result: {
|
||||
status: "READY",
|
||||
selected: session.variant.id,
|
||||
attempted,
|
||||
fallbackReason,
|
||||
pthreadGate: selection.pthreadGate,
|
||||
openCount,
|
||||
failedAttemptCleanup,
|
||||
},
|
||||
session,
|
||||
};
|
||||
}
|
||||
|
||||
export async function bootstrapWebEngineRelease<Project = ArrayBuffer>(
|
||||
expectedReleaseId: string,
|
||||
manifest: WebEngineManifestV2,
|
||||
policy: WebEngineVariantPolicy,
|
||||
capabilities: WasmThreadingCapabilities,
|
||||
dependencies: EngineVariantBootstrapDependencies<Project>,
|
||||
pendingProject?: Project,
|
||||
testHooks?: EngineVariantBootstrapTestHooks,
|
||||
): Promise<WebEngineReleaseBootstrapOutcome<Project>> {
|
||||
const binding = bindWebEngineRelease(expectedReleaseId, manifest);
|
||||
if (binding.status === "REFRESH_REQUIRED") {
|
||||
return { result: binding, session: null };
|
||||
}
|
||||
return bootstrapSelectedWebEngineVariant(
|
||||
binding.manifest,
|
||||
policy,
|
||||
capabilities,
|
||||
dependencies,
|
||||
pendingProject,
|
||||
testHooks,
|
||||
);
|
||||
}
|
||||
|
||||
export function engineVariantStatusLabel(result: EngineVariantBootstrapResult): string {
|
||||
if (result.selected === "pthread") return "Runtime: pthread";
|
||||
return result.fallbackReason
|
||||
? `Runtime: single (pthread fallback: ${result.fallbackReason.code})`
|
||||
: "Runtime: single";
|
||||
}
|
||||
Reference in New Issue
Block a user