From 40f7ff2ba1adce056fe17c6557a0c0ff75d040f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johan=20R=C3=B8ed?= Date: Fri, 20 Mar 2026 09:54:07 +0100 Subject: [PATCH 1/4] Remove `checkFramebufferStatus` from the `Framebuffer` constructor. Added a `checkFramebufferStatus()` method that callers use after attachments are set (at which point `ColorAttachment.set` has already bound the correct FBO). Added the call at all 5 existing callsites. Added try/catch to `redraw()` matching the existing pattern in `triggerRepaint()`. --- CHANGELOG.md | 1 + .../globe_projection_error_measurement.ts | 1 + src/gl/framebuffer.test.ts | 22 +++++++++++++++++++ src/gl/framebuffer.ts | 4 ++++ src/gl/render_pool.ts | 1 + src/render/draw_heatmap.ts | 1 + src/render/draw_hillshade.ts | 1 + src/render/terrain.ts | 1 + src/ui/map.ts | 8 ++++++- src/ui/map_tests/map_render.test.ts | 11 ++++++++++ 10 files changed, 50 insertions(+), 1 deletion(-) create mode 100644 src/gl/framebuffer.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index b876630166d..ff589165d7d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ ### 🐞 Bug fixes - Fix incorrect popup location in case of terrain and `jumpTo` ([#7267](https://github.com/maplibre/maplibre-gl-js/issues/7267)) (by [@HarelM](https://github.com/HarelM)) +- Fix unhandled framebuffer error when tab wakes from sleep: move framebuffer completeness check to after attachments are set, and add error handling to `redraw()` matching `triggerRepaint()` ([#7303](https://github.com/maplibre/maplibre-gl-js/pull/7303)) (by [@johanrd](https://github.com/johanrd)) - _...Add new stuff here..._ - Fix memory leak in VideoSource: remove `playing` event listener and pause video on source removal ([#7279](https://github.com/maplibre/maplibre-gl-js/pull/7279)) (by [@johanrd](https://github.com/johanrd)) - Fix memory leak where typed array views retained StructArray buffers after GPU upload, preventing garbage collection ([#7280](https://github.com/maplibre/maplibre-gl-js/pull/7280)) (by [@johanrd](https://github.com/johanrd)) diff --git a/src/geo/projection/globe_projection_error_measurement.ts b/src/geo/projection/globe_projection_error_measurement.ts index ad830310a7c..4a1d2430ec1 100644 --- a/src/geo/projection/globe_projection_error_measurement.ts +++ b/src/geo/projection/globe_projection_error_measurement.ts @@ -109,6 +109,7 @@ export class ProjectionErrorMeasurement { this._fbo = context.createFramebuffer(this._texWidth, this._texHeight, false, false); this._fbo.colorAttachment.set(texture); + this._fbo.checkFramebufferStatus(); if (isWebGL2(gl)) { this._pbo = gl.createBuffer(); diff --git a/src/gl/framebuffer.test.ts b/src/gl/framebuffer.test.ts new file mode 100644 index 00000000000..14b5da5be2a --- /dev/null +++ b/src/gl/framebuffer.test.ts @@ -0,0 +1,22 @@ +import {describe, test, expect, vi} from 'vitest'; +import {Context} from './context'; +import {Framebuffer} from './framebuffer'; + +describe('Framebuffer', () => { + test('constructor does not check framebuffer status before attachments are set', () => { + const gl = document.createElement('canvas').getContext('webgl'); + vi.spyOn(gl, 'checkFramebufferStatus').mockReturnValue(0); + const context = new Context(gl); + + expect(() => new Framebuffer(context, 256, 256, false, false)).not.toThrow(); + }); + + test('checkFramebufferStatus throws when framebuffer is incomplete', () => { + const gl = document.createElement('canvas').getContext('webgl'); + vi.spyOn(gl, 'checkFramebufferStatus').mockReturnValue(0); + const context = new Context(gl); + const fbo = new Framebuffer(context, 256, 256, false, false); + + expect(() => fbo.checkFramebufferStatus()).toThrow('Framebuffer is not complete'); + }); +}); diff --git a/src/gl/framebuffer.ts b/src/gl/framebuffer.ts index 35f52631b51..da1603e7dcb 100644 --- a/src/gl/framebuffer.ts +++ b/src/gl/framebuffer.ts @@ -28,6 +28,10 @@ export class Framebuffer { } else if (hasStencil) { throw new Error('Stencil cannot be set without depth'); } + } + + checkFramebufferStatus() { + const gl = this.context.gl; if (gl.checkFramebufferStatus(gl.FRAMEBUFFER) !== gl.FRAMEBUFFER_COMPLETE) { throw createFramebufferNotCompleteError(); } diff --git a/src/gl/render_pool.ts b/src/gl/render_pool.ts index 7a628269ba0..34d14af679a 100644 --- a/src/gl/render_pool.ts +++ b/src/gl/render_pool.ts @@ -47,6 +47,7 @@ export class RenderPool { } fbo.depthAttachment.set(this._context.createRenderbuffer(this._context.gl.DEPTH_STENCIL, this._tileSize, this._tileSize)); fbo.colorAttachment.set(texture.texture); + fbo.checkFramebufferStatus(); return {id, fbo, texture, stamp: -1, inUse: false}; } diff --git a/src/render/draw_heatmap.ts b/src/render/draw_heatmap.ts index 06c23a68152..8c5dcdda019 100644 --- a/src/render/draw_heatmap.ts +++ b/src/render/draw_heatmap.ts @@ -227,6 +227,7 @@ function createHeatmapFbo(context: Context, width: number, height: number): Fram const fbo = context.createFramebuffer(width, height, false, false); fbo.colorAttachment.set(texture); + fbo.checkFramebufferStatus(); return fbo; } diff --git a/src/render/draw_hillshade.ts b/src/render/draw_hillshade.ts index d1d78ddc9c7..6fd30d848f2 100644 --- a/src/render/draw_hillshade.ts +++ b/src/render/draw_hillshade.ts @@ -144,6 +144,7 @@ function prepareHillshade( fbo = tile.fbo = context.createFramebuffer(tileSize, tileSize, true, false); fbo.colorAttachment.set(renderTexture.texture); + fbo.checkFramebufferStatus(); } context.bindFramebuffer.set(fbo.framebuffer); diff --git a/src/render/terrain.ts b/src/render/terrain.ts index c1857563f6e..106ea3ee8b7 100644 --- a/src/render/terrain.ts +++ b/src/render/terrain.ts @@ -343,6 +343,7 @@ export class Terrain { this._fbo.depthAttachment.set(painter.context.createRenderbuffer(painter.context.gl.DEPTH_COMPONENT16, width, height)); } this._fbo.colorAttachment.set(texture === 'coords' ? this._fboCoordsTexture.texture : this._fboDepthTexture.texture); + this._fbo.checkFramebufferStatus(); return this._fbo; } diff --git a/src/ui/map.ts b/src/ui/map.ts index bbaba2ba93c..58cd023e9f5 100644 --- a/src/ui/map.ts +++ b/src/ui/map.ts @@ -3726,7 +3726,13 @@ export class Map extends Camera { this._frameRequest.abort(); this._frameRequest = null; } - this._render(0); + try { + this._render(0); + } catch (error) { + if (!isAbortError(error) && !isFramebufferNotCompleteError(error)) { + throw error; + } + } } return this; } diff --git a/src/ui/map_tests/map_render.test.ts b/src/ui/map_tests/map_render.test.ts index d425b49eba7..bb6166f01fc 100644 --- a/src/ui/map_tests/map_render.test.ts +++ b/src/ui/map_tests/map_render.test.ts @@ -80,6 +80,17 @@ test('no render before style loaded', async () => { expect(loaded).toBeTruthy(); }); +test('redraw does not throw on framebuffer error', async () => { + const map = createMap({style: createStyle()}); + await map.once('idle'); + + vi.spyOn(map.painter, 'render').mockImplementationOnce(() => { + throw new Error('Framebuffer is not complete'); + }); + + expect(() => map.redraw()).not.toThrow(); +}); + test('redraw', async () => { const map = createMap(); From 54f69214db5760ed07754de362da2c45abe53487 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johan=20R=C3=B8ed?= Date: Fri, 20 Mar 2026 10:26:06 +0100 Subject: [PATCH 2/4] Make checkFramebufferStatus() bind its own FBO before checking --- src/gl/framebuffer.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/gl/framebuffer.ts b/src/gl/framebuffer.ts index da1603e7dcb..90e985555bf 100644 --- a/src/gl/framebuffer.ts +++ b/src/gl/framebuffer.ts @@ -32,6 +32,7 @@ export class Framebuffer { checkFramebufferStatus() { const gl = this.context.gl; + this.context.bindFramebuffer.set(this.framebuffer); if (gl.checkFramebufferStatus(gl.FRAMEBUFFER) !== gl.FRAMEBUFFER_COMPLETE) { throw createFramebufferNotCompleteError(); } From 6bd3c7488bfe26e857aaf5f43faa1d1185b717e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johan=20R=C3=B8ed?= Date: Fri, 20 Mar 2026 10:35:31 +0100 Subject: [PATCH 3/4] remove framebuffer test: marginal value --- src/gl/framebuffer.test.ts | 22 ---------------------- 1 file changed, 22 deletions(-) delete mode 100644 src/gl/framebuffer.test.ts diff --git a/src/gl/framebuffer.test.ts b/src/gl/framebuffer.test.ts deleted file mode 100644 index 14b5da5be2a..00000000000 --- a/src/gl/framebuffer.test.ts +++ /dev/null @@ -1,22 +0,0 @@ -import {describe, test, expect, vi} from 'vitest'; -import {Context} from './context'; -import {Framebuffer} from './framebuffer'; - -describe('Framebuffer', () => { - test('constructor does not check framebuffer status before attachments are set', () => { - const gl = document.createElement('canvas').getContext('webgl'); - vi.spyOn(gl, 'checkFramebufferStatus').mockReturnValue(0); - const context = new Context(gl); - - expect(() => new Framebuffer(context, 256, 256, false, false)).not.toThrow(); - }); - - test('checkFramebufferStatus throws when framebuffer is incomplete', () => { - const gl = document.createElement('canvas').getContext('webgl'); - vi.spyOn(gl, 'checkFramebufferStatus').mockReturnValue(0); - const context = new Context(gl); - const fbo = new Framebuffer(context, 256, 256, false, false); - - expect(() => fbo.checkFramebufferStatus()).toThrow('Framebuffer is not complete'); - }); -}); From c725d1d6de0a9dc573e19b6aad258982da5d3783 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johan=20R=C3=B8ed?= Date: Fri, 20 Mar 2026 10:38:45 +0100 Subject: [PATCH 4/4] Revert "remove framebuffer test: marginal value" This reverts commit 6bd3c7488bfe26e857aaf5f43faa1d1185b717e6. --- src/gl/framebuffer.test.ts | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 src/gl/framebuffer.test.ts diff --git a/src/gl/framebuffer.test.ts b/src/gl/framebuffer.test.ts new file mode 100644 index 00000000000..14b5da5be2a --- /dev/null +++ b/src/gl/framebuffer.test.ts @@ -0,0 +1,22 @@ +import {describe, test, expect, vi} from 'vitest'; +import {Context} from './context'; +import {Framebuffer} from './framebuffer'; + +describe('Framebuffer', () => { + test('constructor does not check framebuffer status before attachments are set', () => { + const gl = document.createElement('canvas').getContext('webgl'); + vi.spyOn(gl, 'checkFramebufferStatus').mockReturnValue(0); + const context = new Context(gl); + + expect(() => new Framebuffer(context, 256, 256, false, false)).not.toThrow(); + }); + + test('checkFramebufferStatus throws when framebuffer is incomplete', () => { + const gl = document.createElement('canvas').getContext('webgl'); + vi.spyOn(gl, 'checkFramebufferStatus').mockReturnValue(0); + const context = new Context(gl); + const fbo = new Framebuffer(context, 256, 256, false, false); + + expect(() => fbo.checkFramebufferStatus()).toThrow('Framebuffer is not complete'); + }); +});