Back to home page

EIC code displayed by LXR

 
 

    


Warning, /firebird/packages/firebird-ng/display/src/lib/services/root-file.service.ts is written in an unsupported language. File is not indexed.

0001 /**
0002  * The app's facility for ROOT files the user points Firebird at: report what a
0003  * file holds, and convert podio events to DEX. Both run in a worker.
0004  *
0005  * Scope: this service moves BYTES and FACTS. It never decides what a file is
0006  * for - `probe()` returns the file's top-level keys and the routing control
0007  * (FileOpenRouterService) asks the registered loaders what to do with them. The
0008  * DEX conversion itself belongs to @dexvis/root2dex; this is only its
0009  * transport.
0010  *
0011  * Files are opened under handles (`createHandle()`): each client keeps its own
0012  * file open across conversions, so the "pick a file, then step through events"
0013  * loop pays the tree-metadata read once, and one client opening a file never
0014  * replaces another client's. Nothing here reads the whole file: a multi-GB
0015  * file is fine, only the baskets of the requested events cross the wire.
0016  *
0017  * Bundle note: the worker is created on the first use, so jsroot and the
0018  * converter are fetched when a user actually opens a file.
0019  */
0020 
0021 import { Injectable, inject, signal } from '@angular/core';
0022 import type { FileContentProbe } from '@dexvis/firebird-core';
0023 import type {
0024   RootConvertOptions,
0025   RootFileRequest,
0026   RootFileResponse,
0027   RootFileSource,
0028 } from '@dexvis/firebird-ng/workers/root-file';
0029 import { FIREBIRD_WORKERS, UrlService } from '@dexvis/firebird-ng/api';
0030 
0031 /** What was learned about a file when it was opened. */
0032 export interface OpenedRootFile {
0033   sourceName: string;
0034   model: string;
0035   entryCount: number;
0036   /**
0037    * The collection groups the model's conversion knows — what a UI offers as
0038    * conversion checkboxes. Names match `pyrobird convert --collections`.
0039    */
0040   collectionGroups: string[];
0041 }
0042 
0043 /** Converted events, plus whatever the converter complained about. */
0044 export interface ConvertedEvents {
0045   entries: number[];
0046   dex: unknown;
0047   warnings: string[];
0048 }
0049 
0050 interface PendingRequest {
0051   resolve: (value: never) => void;
0052   reject: (error: Error) => void;
0053   /** The handle the request belongs to; undefined for handle-free requests (probe). */
0054   handle?: number;
0055 }
0056 
0057 /**
0058  * One client's view of the worker: a file opened under its own handle id, so
0059  * that the data selector's picker and the display's loads never replace each
0060  * other's open file. Close it when done; closing releases only this handle's
0061  * file, and the handle can open another file afterwards.
0062  */
0063 export class RootFileHandle {
0064   /** The file currently open under this handle, or null. */
0065   readonly openedFile = signal<OpenedRootFile | null>(null);
0066   /** True while an open or a conversion of this handle is in flight. */
0067   readonly busy = signal(false);
0068 
0069   private inFlight = 0;
0070   /** Bumped by every open and by close: only the latest open sets `openedFile`. */
0071   private openToken = 0;
0072 
0073   constructor(
0074     readonly id: number,
0075     private readonly service: RootFileService,
0076   ) {}
0077 
0078   /**
0079    * Opens a ROOT file and reads its tree metadata, replacing what this handle
0080    * had open. When opens overlap, only the latest one sets `openedFile`.
0081    */
0082   async open(source: File | string): Promise<OpenedRootFile> {
0083     const token = ++this.openToken;
0084     this.openedFile.set(null);
0085     const opened = await this.track(this.service.request<OpenedRootFile>(this.id, requestId => ({
0086       type: 'open',
0087       requestId,
0088       handle: this.id,
0089       source: this.service.sourceOf(source),
0090     })));
0091     const file: OpenedRootFile = {
0092       sourceName: opened.sourceName,
0093       model: opened.model,
0094       entryCount: opened.entryCount,
0095       collectionGroups: opened.collectionGroups,
0096     };
0097     if (token === this.openToken) this.openedFile.set(file);
0098     return file;
0099   }
0100 
0101   /**
0102    * Converts events of this handle's open file to one DEX document.
0103    *
0104    * @param entries Entry numbers as typed by the user: '1', '0,2,4-5'.
0105    *   Entries outside the file are dropped with one warning in
0106    *   `warnings`; a selection with no entry in the file is rejected.
0107    * @param options Collection groups to convert (names from the opened
0108    *   file's `collectionGroups`; absent or empty means all groups) and the
0109    *   sim hit collections kept out of MC-truth trajectories.
0110    */
0111   async convert(entries: string, options: RootConvertOptions = {}): Promise<ConvertedEvents> {
0112     const { collections, trajectoryExcludedCollections } = options;
0113     const result = await this.track(this.service.request<{ entries: number[]; dex: unknown; warnings: string[] }>(
0114       this.id,
0115       requestId => ({ type: 'convert', requestId, handle: this.id, entries, collections, trajectoryExcludedCollections }),
0116     ));
0117     return { entries: result.entries, dex: result.dex, warnings: result.warnings };
0118   }
0119 
0120   /** Releases this handle's file in the worker; its in-flight requests reject. */
0121   close(): void {
0122     this.openToken++;
0123     this.openedFile.set(null);
0124     this.service.release(this.id);
0125   }
0126 
0127   private async track<T>(request: Promise<T>): Promise<T> {
0128     this.inFlight++;
0129     this.busy.set(true);
0130     try {
0131       return await request;
0132     } finally {
0133       this.inFlight--;
0134       this.busy.set(this.inFlight > 0);
0135     }
0136   }
0137 }
0138 
0139 @Injectable({ providedIn: 'root' })
0140 export class RootFileService {
0141   private readonly urls = inject(UrlService);
0142   /** The worker factories of the application (withWorkers). */
0143   private readonly workers = inject(FIREBIRD_WORKERS, { optional: true });
0144 
0145   private worker: Worker | null = null;
0146   private readonly pending = new Map<string, PendingRequest>();
0147   private requestCounter = 0;
0148   private handleCounter = 0;
0149 
0150   /** True when this browser can run the converter at all. */
0151   get isSupported(): boolean {
0152     return typeof Worker !== 'undefined';
0153   }
0154 
0155   /** A new handle: one client's open file in the worker. */
0156   createHandle(): RootFileHandle {
0157     return new RootFileHandle(++this.handleCounter, this);
0158   }
0159 
0160   /**
0161    * Reports the file's top-level keys with their ROOT class names. Cheap: only
0162    * the key directory is read, no event data and no geometry.
0163    */
0164   async probe(source: File | string): Promise<FileContentProbe> {
0165     const result = await this.request<{ probe: FileContentProbe }>(undefined, requestId => ({
0166       type: 'probe',
0167       requestId,
0168       source: this.sourceOf(source),
0169     }));
0170     return result.probe;
0171   }
0172 
0173   /**
0174    * URL aliases (`asset://`, pack aliases) and server-relative paths are resolved
0175    * here, on the main thread: the worker's jsroot reader fetches whatever URL
0176    * it is handed and knows nothing about Firebird's schemes.
0177    */
0178   sourceOf(source: File | string): RootFileSource {
0179     return typeof source === 'string'
0180       ? { kind: 'url', url: this.urls.resolveDownloadUrl(source) }
0181       : { kind: 'file', file: source };
0182   }
0183 
0184   /** Sends one request to the worker; `handle` ties it to a RootFileHandle. */
0185   request<T>(handle: number | undefined, build: (requestId: string) => RootFileRequest): Promise<T> {
0186     const worker = this.ensureWorker();
0187     const requestId = `root-file-${++this.requestCounter}`;
0188     return new Promise<T>((resolve, reject) => {
0189       this.pending.set(requestId, {
0190         resolve: resolve as (value: never) => void,
0191         reject,
0192         handle,
0193       });
0194       worker.postMessage(build(requestId));
0195     });
0196   }
0197 
0198   /** Drops a handle's file in the worker and rejects its in-flight requests. */
0199   release(handle: number): void {
0200     for (const [requestId, request] of this.pending) {
0201       if (request.handle !== handle) continue;
0202       this.pending.delete(requestId);
0203       request.reject(new Error('The ROOT file was closed'));
0204     }
0205     if (!this.worker) return;
0206     const requestId = `root-file-${++this.requestCounter}`;
0207     this.worker.postMessage({ type: 'close', requestId, handle } satisfies RootFileRequest);
0208   }
0209 
0210   private ensureWorker(): Worker {
0211     if (this.worker) return this.worker;
0212     if (!this.isSupported) {
0213       throw new Error('Web Workers are not available, cannot read ROOT files in this browser');
0214     }
0215     if (!this.workers) {
0216       throw new Error('No ROOT file worker: add withWorkers({ geometry, rootFile }) to provideFirebird()');
0217     }
0218     const worker = this.workers.rootFile();
0219     worker.onmessage = ({ data }: MessageEvent<RootFileResponse>) => this.onMessage(data);
0220     worker.onerror = error => {
0221       const message = `ROOT file worker error: ${error.message}`;
0222       console.error(`[RootFileService]: ${message}`);
0223       for (const request of this.pending.values()) request.reject(new Error(message));
0224       this.pending.clear();
0225       // A broken worker is dropped; the next request starts a fresh one
0226       worker.terminate();
0227       if (this.worker === worker) this.worker = null;
0228     };
0229     this.worker = worker;
0230     return worker;
0231   }
0232 
0233   private onMessage(data: RootFileResponse): void {
0234     const request = this.pending.get(data.requestId);
0235     if (!request) return;
0236     this.pending.delete(data.requestId);
0237     if (data.type === 'error') {
0238       request.reject(new Error(data.error));
0239       return;
0240     }
0241     if (data.type === 'converted') {
0242       // The transfer delta is the structured-clone serialize + queue +
0243       // deserialize cost of shipping the DEX document out of the worker
0244       const transferMs = Date.now() - data.postedAtMs;
0245       if (data.convertMs > 100 || transferMs > 100) {
0246         console.log(`[load-timing] root worker: convert ${data.convertMs.toFixed(1)} ms, ` +
0247           `worker->main transfer ${transferMs} ms`);
0248       }
0249     }
0250     request.resolve(data as never);
0251   }
0252 }