Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions src/core/CoreNode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2735,16 +2735,19 @@ export class CoreNode extends EventEmitter {
const def = this.stage.defShaderNode;
if (this.props.shader === def) return;
this.hasShaderUpdater = false;
this.hasShaderTimeFn = false;
this.props.shader = def;
this.setUpdateType(UpdateType.IsRenderable);
return;
}
if (this.props.shader === shader) {
return;
}

this.hasShaderUpdater = shader.update !== undefined;
this.hasShaderTimeFn = shader.time !== undefined;

if (shader.shaderKey !== 'default') {
this.hasShaderUpdater = shader.update !== undefined;
this.hasShaderTimeFn = shader.time !== undefined;
shader.attachNode(this);
}

Expand Down
94 changes: 94 additions & 0 deletions src/core/renderers/webgl/WebGlCtxTexture.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
/**
* Tests proving the WebGL OOM / ctxTexture undefined crash scenario.
*
* Crash chain being simulated:
* GL_OUT_OF_MEMORY from texImage2D
* → Texture.release() sets ctxTexture = undefined (via microtask)
* → WebGlRenderer.addQuad() reads tx.ctxTexture as WebGlCtxTexture (undefined)
* → CoreNode.addTexture(undefined) pushes undefined into renderOpTextures
* → WebGlShaderProgram.bindTextures() reads textures[0]!.ctxTexture
* → TypeError: Cannot read property 'ctxTexture' of undefined ← CRASH
*
* All tests here are RED before the fix and GREEN after.
*/

import { describe, expect, it, vi } from 'vitest';
import { WebGlShaderProgram } from './WebGlShaderProgram.js';
import { CoreNode } from '../../CoreNode.js';
import type { WebGlCtxTexture } from './WebGlCtxTexture.js';

// ---------------------------------------------------------------------------
// Test 1 — bindTextures crashes when textures[0] is undefined
//
// Directly mirrors the crash from the production stack trace:
// fb.bindTextures → textures[0]!.ctxTexture
// → TypeError: Cannot read property 'ctxTexture' of undefined
// ---------------------------------------------------------------------------
describe('WebGlShaderProgram.bindTextures', () => {
it('does not throw when textures[0] is undefined', () => {
// Create a minimal instance that bypasses the GL-context-requiring constructor
const instance = Object.create(
WebGlShaderProgram.prototype,
) as WebGlShaderProgram;
(instance as any).glw = { activeTexture: vi.fn(), bindTexture: vi.fn() };

// After Fix B: bindTextures returns early instead of crashing
expect(() =>
instance.bindTextures([undefined as unknown as WebGlCtxTexture]),
).not.toThrow();
});
});

// ---------------------------------------------------------------------------
// Test 2 — addTexture silently accepts undefined and stores it
//
// The crash is silent at this layer: passing an undefined ctxTexture to
// CoreNode.addTexture does NOT throw — it quietly pushes undefined into
// renderOpTextures, poisoning the array for bindTextures later.
// ---------------------------------------------------------------------------
describe('CoreNode.addTexture with undefined ctxTexture', () => {
it('accepts undefined without throwing and stores it in renderOpTextures', () => {
// Call via prototype to avoid the complex CoreNode constructor
const fakeCtx = { renderOpTextures: [] as WebGlCtxTexture[] };
const idx = CoreNode.prototype.addTexture.call(
fakeCtx,
undefined as unknown as WebGlCtxTexture,
);

// No error raised here — that is the problem
expect(idx).toBe(0);
// undefined is now stored; any subsequent bindTextures call will crash
expect(fakeCtx.renderOpTextures[0]).toBeUndefined();
});
});

// ---------------------------------------------------------------------------
// Test 3 — full crash chain: undefined ctxTexture → addTexture → bindTextures
//
// End-to-end proof that the chain fails before any fix is applied.
// ---------------------------------------------------------------------------
describe('full OOM crash chain: undefined ctxTexture → bindTextures crash', () => {
it('does not crash when undefined ctxTexture propagates to bindTextures', () => {
// Simulate: GPU OOM causes Texture.release() → ctxTexture = undefined
const undefinedCtxTexture = undefined as unknown as WebGlCtxTexture;

// Simulate: WebGlRenderer.addQuad reads tx.ctxTexture (undefined) and
// passes it to curRenderOp.addTexture — no error thrown here
const fakeRenderOp = { renderOpTextures: [] as WebGlCtxTexture[] };
CoreNode.prototype.addTexture.call(fakeRenderOp, undefinedCtxTexture);

// Confirm undefined is still sitting in renderOpTextures (addTexture is unchanged)
expect(fakeRenderOp.renderOpTextures[0]).toBeUndefined();

// Simulate: bindRenderOp calls bindTextures with the poisoned array
const program = Object.create(
WebGlShaderProgram.prototype,
) as WebGlShaderProgram;
(program as any).glw = { activeTexture: vi.fn(), bindTexture: vi.fn() };

// After Fix B: bindTextures returns early — the draw loop is protected
expect(() =>
program.bindTextures(fakeRenderOp.renderOpTextures),
).not.toThrow();
});
});
25 changes: 16 additions & 9 deletions src/core/renderers/webgl/WebGlRenderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -411,6 +411,18 @@ export class WebGlRenderer extends CoreRenderer {
this.curSdfRenderOp = null;
}

const props = node.props;
let tx = props.texture || this.stage.defaultTexture!;

if (tx.type === TextureType.subTexture) {
tx = (tx as SubTexture).parentTexture;
}

const ctx = tx.ctxTexture as WebGlCtxTexture | undefined;
if (ctx === undefined) {
return;
}

const reuse = this.reuseRenderOp(node);

// During RTT rendering, always use sequential allocation and write data
Expand Down Expand Up @@ -439,19 +451,14 @@ export class WebGlRenderer extends CoreRenderer {
this.newRenderOp(node, i);
}

const props = node.props;
let tx = props.texture || this.stage.defaultTexture!;

if (tx.type === TextureType.subTexture) {
tx = (tx as SubTexture).parentTexture;
if (!this.curRenderOp) {
return;
}

const texture = tx.ctxTexture as WebGlCtxTexture;
let tidx = this.curRenderOp!.addTexture(texture);
let tidx = this.curRenderOp.addTexture(ctx);

if (tidx === 0xffffffff) {
this.newRenderOp(node, i);
tidx = this.curRenderOp!.addTexture(texture);
tidx = this.curRenderOp.addTexture(ctx);
}

// Only rewrite the CPU-side buffer when the node is dirty.
Expand Down
5 changes: 3 additions & 2 deletions src/core/renderers/webgl/WebGlShaderProgram.ts
Original file line number Diff line number Diff line change
Expand Up @@ -297,11 +297,12 @@ export class WebGlShaderProgram implements CoreShaderProgram {
}

bindTextures(textures: WebGlCtxTexture[]) {
if (textures[0] === undefined) {
const t = textures[0];
if (t === undefined) {
return;
}
this.glw.activeTexture(0);
this.glw.bindTexture(textures[0].ctxTexture);
this.glw.bindTexture(t.ctxTexture);
}

attach(): void {
Expand Down
22 changes: 10 additions & 12 deletions src/core/text-rendering/SdfTextRenderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ import type { WebGlShaderNode } from '../renderers/webgl/WebGlShaderNode.js';
import { isProductionEnvironment } from '../../utils.js';
import type { TextLayout, GlyphLayout } from './TextRenderer.js';
import { mapTextLayout } from './TextLayoutEngine.js';
import type { RectWithValid } from '../lib/utils.js';
import type { Dimensions } from '../../common/CommonTypes.js';

// Type definition to match interface
const type = 'sdf' as const;
Expand Down Expand Up @@ -144,14 +146,12 @@ const renderQuads = (
cache.vertices,
cache.glyphCount,
ctxTexture,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
renderProps.clippingRect as any,
renderProps.clippingRect,
renderProps.worldAlpha,
layout.width,
layout.height,
renderProps.parentHasRenderTexture,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
renderProps.framebufferDimensions as any,
renderProps.framebufferDimensions,
sdfShader!,
);
return null;
Expand All @@ -160,7 +160,6 @@ const renderQuads = (

// --- Cache-miss slow path -----------------------------------------------
const startIdx = webGlRenderer.sdfBufferIdx;

webGlRenderer.addSdfQuads(
layout.glyphs,
layout.fontScale,
Expand All @@ -169,13 +168,12 @@ const renderQuads = (
renderProps.worldAlpha,
layout.distanceRange,
ctxTexture,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
renderProps.clippingRect as any,

renderProps.clippingRect,
layout.width,
layout.height,
renderProps.parentHasRenderTexture,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
renderProps.framebufferDimensions as any,
renderProps.framebufferDimensions,
sdfShader!,
);

Expand Down Expand Up @@ -242,9 +240,9 @@ const generateTextLayout = (
const maxHeight = props.maxHeight;
const [
lines,
remainingLines,
hasRemainingText,
bareLineHeight,
_remainingLines,
_hasRemainingText,
_bareLineHeight,
lineHeightPx,
effectiveWidth,
effectiveHeight,
Expand Down
6 changes: 4 additions & 2 deletions src/core/text-rendering/TextRenderer.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import type { Dimensions } from '../../common/CommonTypes.js';
import type { CoreTextNodeProps } from '../CoreTextNode.js';
import type { RectWithValid } from '../lib/utils.js';
import type { CoreRenderer } from '../renderers/CoreRenderer.js';
import type { SdfRenderOp } from '../renderers/webgl/SdfRenderOp.js';
import type { Stage } from '../Stage.js';
Expand Down Expand Up @@ -345,11 +347,11 @@ export interface TextRenderProps {
offsetY: number;
worldAlpha: number;
globalTransform: Float32Array;
clippingRect: unknown;
clippingRect: RectWithValid;
width: number;
height: number;
parentHasRenderTexture: boolean;
framebufferDimensions: unknown;
framebufferDimensions: Dimensions | null;
stage: Stage;
/** Optional SDF vertex cache — passed by CoreTextNode for cache-hit fast path. */
sdfCache?: SdfVertexCache;
Expand Down
6 changes: 5 additions & 1 deletion src/main-api/Renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -516,7 +516,7 @@ export class RendererMain extends EventEmitter {
boundsMargin: settings.boundsMargin || 0,
deviceLogicalPixelRatio: settings.deviceLogicalPixelRatio || 1,
devicePhysicalPixelRatio:
settings.devicePhysicalPixelRatio || window.devicePixelRatio || 1,
settings.devicePhysicalPixelRatio || this.windowDevicePixelRatio() || 1,
clearColor: settings.clearColor ?? 0x00000000,
fpsUpdateInterval: settings.fpsUpdateInterval || 0,
enableClear: settings.enableClear ?? true,
Expand Down Expand Up @@ -1039,4 +1039,8 @@ export class RendererMain extends EventEmitter {
this.stage.options.targetFPS = fps > 0 ? fps : 0;
this.stage.updateTargetFrameTime();
}

private windowDevicePixelRatio() {
return typeof window !== 'undefined' ? window.devicePixelRatio : undefined;
}
}
Loading