From dfae1be8566047c9253d0e239507d177dc578cc8 Mon Sep 17 00:00:00 2001 From: pupiltong <12288479+PupilTong@users.noreply.github.com> Date: Thu, 26 Mar 2026 20:15:38 +0800 Subject: [PATCH 1/2] fix: Prevent partial bundle loading and double fetching in `TemplateManager.fetchBundle` by handling concurrent requests and renaming template-related methods to bundle. --- .changeset/fix-bundle-race-condition.md | 5 + .../web-core/tests/template-manager.spec.ts | 79 +++++++++-- .../ts/client/mainthread/LynxViewInstance.ts | 8 +- .../ts/client/mainthread/TemplateManager.ts | 126 ++++++++++++------ .../mainthread/createMainThreadGlobalAPIs.ts | 2 +- 5 files changed, 162 insertions(+), 58 deletions(-) create mode 100644 .changeset/fix-bundle-race-condition.md diff --git a/.changeset/fix-bundle-race-condition.md b/.changeset/fix-bundle-race-condition.md new file mode 100644 index 0000000000..18f8396ddd --- /dev/null +++ b/.changeset/fix-bundle-race-condition.md @@ -0,0 +1,5 @@ +--- +"@lynx-js/web-core": patch +--- + +fix(web-core): avoid partial bundle loading and double fetching when fetchBundle is called concurrently for the same url. diff --git a/packages/web-platform/web-core/tests/template-manager.spec.ts b/packages/web-platform/web-core/tests/template-manager.spec.ts index 8da1ff4ce6..b8402996ed 100644 --- a/packages/web-platform/web-core/tests/template-manager.spec.ts +++ b/packages/web-platform/web-core/tests/template-manager.spec.ts @@ -90,7 +90,7 @@ describe('Template Manager', () => { ); // Verify data using getCustomSection - const customSections = templateManager.getTemplate(templateUrl) + const customSections = templateManager.getBundle(templateUrl) ?.customSections; const decoder = new TextDecoder('utf-16le'); const decodedCustomSections = JSON.parse( @@ -130,16 +130,16 @@ describe('Template Manager', () => { .rejects.toThrow('Unsupported version: 2'); // Verify template is removed - expect(templateManager.getTemplate(templateUrl)?.customSections) + expect(templateManager.getBundle(templateUrl)?.customSections) .toBeUndefined(); }); /* test('should throw error for create same template twice', () => { const templateUrl = 'http://example.com/template_duplicate_url_test'; - templateManager.createTemplate(templateUrl); + templateManager.createBundle(templateUrl); expect(() => { - templateManager.createTemplate(templateUrl); + templateManager.createBundle(templateUrl); }).toThrow(); }); */ @@ -172,7 +172,7 @@ describe('Template Manager', () => { ); // Verify data using getCustomSection - const customSections = templateManager.getTemplate( + const customSections = templateManager.getBundle( 'http://example.com/template', )?.customSections; const decoder = new TextDecoder('utf-16le'); @@ -185,17 +185,17 @@ describe('Template Manager', () => { /* test('should remove template correctly', () => { const templateUrl = 'http://example.com/template_to_remove'; - templateManager.createTemplate(templateUrl); + templateManager.createBundle(templateUrl); // Manually set a custom section to verify existence templateManager.setCustomSection(templateUrl, { test: 'data' }); - expect(templateManager.getTemplate(templateUrl)?.customSections).toEqual({ + expect(templateManager.getBundle(templateUrl)?.customSections).toEqual({ test: 'data', }); - templateManager.removeTemplate(templateUrl); + templateManager.removeBundle(templateUrl); - expect(templateManager.getTemplate(templateUrl)?.customSections) + expect(templateManager.getBundle(templateUrl)?.customSections) .toBeUndefined(); }); */ @@ -228,7 +228,7 @@ describe('Template Manager', () => { ), ).rejects.toThrow('Stream failed'); - expect(templateManager.getTemplate(templateUrl)?.customSections) + expect(templateManager.getBundle(templateUrl)?.customSections) .toBeUndefined(); }); @@ -394,4 +394,63 @@ describe('Template Manager', () => { }), ); }); + + test('should not result in partial bundle when fetchBundle is called twice concurrently', async () => { + const encoded = encode(sampleTasm); + + const stream = new ReadableStream({ + async start(controller) { + // Enqueue with a small delay so concurrent requests wait + await new Promise(resolve => setTimeout(resolve, 10)); + controller.enqueue(encoded); + controller.close(); + }, + }); + + (globalThis.fetch as any).mockResolvedValue({ + ok: true, + status: 200, + statusText: 'OK', + body: stream, + }); + + const instance1 = { + ...mockLynxViewInstance, + onPageConfigReady: vi.fn(), + backgroundThread: { markTiming: vi.fn() }, + }; + const instance2 = { + ...mockLynxViewInstance, + onPageConfigReady: vi.fn(), + backgroundThread: { markTiming: vi.fn() }, + }; + + // Trigger both concurrently + await Promise.all([ + templateManager.fetchBundle( + 'http://example.com/template_concurrent', + Promise.resolve(instance1 as unknown as LynxViewInstance), + false, + false, + ), + templateManager.fetchBundle( + 'http://example.com/template_concurrent', + Promise.resolve(instance2 as unknown as LynxViewInstance), + false, + false, + ), + ]); + + // Verify both finish correctly + const customSections = templateManager.getBundle( + 'http://example.com/template_concurrent', + )?.customSections; + const decoder = new TextDecoder('utf-16le'); + const decodedCustomSections = JSON.parse( + decoder.decode(customSections as unknown as Uint8Array), + ); + expect(decodedCustomSections).toEqual(sampleTasm.customSections); + expect(instance1.onPageConfigReady).toHaveBeenCalled(); + expect(instance2.onPageConfigReady).toHaveBeenCalled(); + }); }); diff --git a/packages/web-platform/web-core/ts/client/mainthread/LynxViewInstance.ts b/packages/web-platform/web-core/ts/client/mainthread/LynxViewInstance.ts index d93b8c51d6..18eb56975c 100644 --- a/packages/web-platform/web-core/ts/client/mainthread/LynxViewInstance.ts +++ b/packages/web-platform/web-core/ts/client/mainthread/LynxViewInstance.ts @@ -155,7 +155,7 @@ export class LynxViewInstance implements AsyncDisposable { async onMTSScriptsLoaded(currentUrl: string, isLazy: boolean) { this.backgroundThread.markTiming('lepus_execute_start'); - const urlMap = templateManager.getTemplate(currentUrl) + const urlMap = templateManager.getBundle(currentUrl) ?.lepusCode as Record; this.lepusCodeUrls.set( currentUrl, @@ -181,8 +181,8 @@ export class LynxViewInstance implements AsyncDisposable { this.backgroundThread.startWebWorker( processedData, this.globalprops, - templateManager.getTemplate(this.templateUrl)!.config!.cardType, - templateManager.getTemplate(this.templateUrl)?.customSections as Record< + templateManager.getBundle(this.templateUrl)!.config!.cardType, + templateManager.getBundle(this.templateUrl)?.customSections as Record< string, Cloneable >, @@ -197,7 +197,7 @@ export class LynxViewInstance implements AsyncDisposable { } async onBTSScriptsLoaded(url: string) { - const btsUrls = templateManager.getTemplate(url) + const btsUrls = templateManager.getBundle(url) ?.backgroundCode as Record< string, string diff --git a/packages/web-platform/web-core/ts/client/mainthread/TemplateManager.ts b/packages/web-platform/web-core/ts/client/mainthread/TemplateManager.ts index 13ca29afbf..6eda6f551b 100644 --- a/packages/web-platform/web-core/ts/client/mainthread/TemplateManager.ts +++ b/packages/web-platform/web-core/ts/client/mainthread/TemplateManager.ts @@ -24,7 +24,9 @@ const wasm = import( ); export class TemplateManager { - readonly #templates: Map = new Map(); + readonly #bundles: Map = new Map(); + readonly #loadingBundles: Map = new Map(); + readonly #loadingPromises: Map> = new Map(); readonly #lynxViewInstancesMap: Map< string, Promise @@ -49,10 +51,10 @@ export class TemplateManager { transformVH: boolean, overrideConfig?: Record, ): Promise { - if (this.#templates.has(url) && !overrideConfig) { + if (this.#bundles.has(url) && !overrideConfig) { return (async () => { - const template = this.#templates.get(url); - const config = (template?.config || {}) as PageConfig; + const bundle = this.#bundles.get(url); + const config = (bundle?.config || {}) as PageConfig; const lynxViewInstance = await lynxViewInstancePromise; lynxViewInstance.backgroundThread.markTiming('decode_start'); lynxViewInstance.onPageConfigReady(config); @@ -60,15 +62,28 @@ export class TemplateManager { lynxViewInstance.onMTSScriptsLoaded(url, config.isLazy === 'true'); lynxViewInstance.onBTSScriptsLoaded(url); })(); + } else if (this.#loadingPromises.has(url)) { + return this.#loadingPromises.get(url)!.then(async () => { + const bundle = this.#bundles.get(url); + const config = (bundle?.config || {}) as PageConfig; + const lynxViewInstance = await lynxViewInstancePromise; + lynxViewInstance.backgroundThread.markTiming('decode_start'); + lynxViewInstance.onPageConfigReady(config); + lynxViewInstance.onStyleInfoReady(url); + lynxViewInstance.onMTSScriptsLoaded(url, config.isLazy === 'true'); + lynxViewInstance.onBTSScriptsLoaded(url); + }); } else { - this.createTemplate(url); - return this.#load( + this.createBundle(url); + const promise = this.#load( url, lynxViewInstancePromise, transformVW, transformVH, overrideConfig, ); + this.#loadingPromises.set(url, promise); + return promise; } } @@ -100,7 +115,7 @@ export class TemplateManager { overrideConfig, }; this.#worker!.postMessage(msg); - return new Promise((resolve, reject) => { + return new Promise((resolve, reject) => { this.#pendingResolves.set(url, { resolve, reject }); }); } @@ -174,13 +189,19 @@ export class TemplateManager { this.#handleSection(msg, lynxViewInstancePromise); break; case 'error': - console.error(`Error decoding template ${url}:`, msg.error); + console.error(`Error decoding bundle ${url}:`, msg.error); this.#cleanup(url); - this.#removeTemplate(url); + this.#removeBundle(url); this.#rejectPromise(url, new Error(msg.error)); + this.#loadingPromises.delete(url); break; case 'done': this.#cleanup(url); + const bundle = this.#loadingBundles.get(url); + if (bundle) { + this.#bundles.set(url, bundle); + this.#loadingBundles.delete(url); + } /* TODO: The promise resolution is deferred inside .then() without error handling. * */ @@ -188,6 +209,7 @@ export class TemplateManager { instance.backgroundThread.markTiming('decode_end'); instance.backgroundThread.markTiming('load_template_start'); this.#resolvePromise(url); + this.#loadingPromises.delete(url); }); break; } @@ -217,9 +239,9 @@ export class TemplateManager { new Uint8Array(data as ArrayBuffer), document, ); - const template = this.#templates.get(url); - if (template) { - template.styleSheet = resource; + const bundle = this.#loadingBundles.get(url); + if (bundle) { + bundle.styleSheet = resource; } instance.onStyleInfoReady(url); break; @@ -250,53 +272,71 @@ export class TemplateManager { this.#lynxViewInstancesMap.delete(url); } - createTemplate(url: string) { - if (this.#templates.has(url)) { - // remove the template and revoke URLs - const template = this.#templates.get(url); - if (template) { - if (template.lepusCode) { - for (const blobUrl of Object.values(template.lepusCode)) { + createBundle(url: string) { + if (this.#bundles.has(url)) { + const bundle = this.#bundles.get(url); + if (bundle) { + if (bundle.lepusCode) { + for (const blobUrl of Object.values(bundle.lepusCode)) { + URL.revokeObjectURL(blobUrl); + } + } + if (bundle.backgroundCode) { + for (const blobUrl of Object.values(bundle.backgroundCode)) { + URL.revokeObjectURL(blobUrl); + } + } + if (bundle.styleSheet) { + bundle.styleSheet.free(); + } + } + this.#bundles.delete(url); + } + if (this.#loadingBundles.has(url)) { + const bundle = this.#loadingBundles.get(url); + if (bundle) { + if (bundle.lepusCode) { + for (const blobUrl of Object.values(bundle.lepusCode)) { URL.revokeObjectURL(blobUrl); } } - if (template.backgroundCode) { - for (const blobUrl of Object.values(template.backgroundCode)) { + if (bundle.backgroundCode) { + for (const blobUrl of Object.values(bundle.backgroundCode)) { URL.revokeObjectURL(blobUrl); } } - if (template.styleSheet) { - template.styleSheet.free(); + if (bundle.styleSheet) { + bundle.styleSheet.free(); } } - this.#templates.delete(url); + this.#loadingBundles.delete(url); } - this.#templates.set(url, {}); + this.#loadingBundles.set(url, {}); } - #removeTemplate(url: string) { - this.createTemplate(url); // This actually clears it in current logic - this.#templates.delete(url); + #removeBundle(url: string) { + this.createBundle(url); // This actually clears it in current logic + this.#loadingBundles.delete(url); } #setConfig(url: string, config: PageConfig) { - const template = this.#templates.get(url); - if (template) { - template.config = config; + const bundle = this.#loadingBundles.get(url); + if (bundle) { + bundle.config = config; } } #setLepusCode(url: string, lepusCode: Record) { - const template = this.#templates.get(url); - if (template) { - template.lepusCode = lepusCode; + const bundle = this.#loadingBundles.get(url); + if (bundle) { + bundle.lepusCode = lepusCode; } } #setCustomSection(url: string, customSections: Record) { - const template = this.#templates.get(url); - if (template) { - template.customSections = customSections; + const bundle = this.#loadingBundles.get(url); + if (bundle) { + bundle.customSections = customSections; } } @@ -304,18 +344,18 @@ export class TemplateManager { url: string, backgroundCode: Record, ) { - const template = this.#templates.get(url); - if (template) { - template.backgroundCode = backgroundCode; + const bundle = this.#loadingBundles.get(url); + if (bundle) { + bundle.backgroundCode = backgroundCode; } } - public getTemplate(url: string): DecodedTemplate | undefined { - return this.#templates.get(url); + public getBundle(url: string): DecodedTemplate | undefined { + return this.#bundles.get(url) || this.#loadingBundles.get(url); } public getStyleSheet(url: string): any { - return this.#templates.get(url)?.styleSheet; + return this.getBundle(url)?.styleSheet; } } diff --git a/packages/web-platform/web-core/ts/client/mainthread/createMainThreadGlobalAPIs.ts b/packages/web-platform/web-core/ts/client/mainthread/createMainThreadGlobalAPIs.ts index c26d58631d..60a85fd4cd 100644 --- a/packages/web-platform/web-core/ts/client/mainthread/createMainThreadGlobalAPIs.ts +++ b/packages/web-platform/web-core/ts/client/mainthread/createMainThreadGlobalAPIs.ts @@ -32,7 +32,7 @@ function createMainThreadLynx( }, __globalProps: lynxViewInstance.globalprops, getCustomSectionSync(key: string) { - return (templateManager.getTemplate( + return (templateManager.getBundle( lynxViewInstance.templateUrl, )?.customSections as any)?.[key] ?.content; From 9dca8a0b154290eae4431afc901b2f4915a2ba8d Mon Sep 17 00:00:00 2001 From: pupiltong <12288479+PupilTong@users.noreply.github.com> Date: Fri, 27 Mar 2026 12:14:52 +0800 Subject: [PATCH 2/2] +fix --- .../web-core/ts/client/mainthread/TemplateManager.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/web-platform/web-core/ts/client/mainthread/TemplateManager.ts b/packages/web-platform/web-core/ts/client/mainthread/TemplateManager.ts index 6eda6f551b..0bb03322d1 100644 --- a/packages/web-platform/web-core/ts/client/mainthread/TemplateManager.ts +++ b/packages/web-platform/web-core/ts/client/mainthread/TemplateManager.ts @@ -202,14 +202,14 @@ export class TemplateManager { this.#bundles.set(url, bundle); this.#loadingBundles.delete(url); } + this.#resolvePromise(url); + this.#loadingPromises.delete(url); /* TODO: The promise resolution is deferred inside .then() without error handling. * */ lynxViewInstancePromise.then((instance) => { instance.backgroundThread.markTiming('decode_end'); instance.backgroundThread.markTiming('load_template_start'); - this.#resolvePromise(url); - this.#loadingPromises.delete(url); }); break; }