Skip to content
Draft
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
10 changes: 3 additions & 7 deletions src/core/CoreNode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,6 @@ import type { TextureOptions } from './CoreTextureManager.js';
import type { CoreRenderer } from './renderers/CoreRenderer.js';
import type { Stage } from './Stage.js';
import {
TextureType,
type Texture,
type TextureFailedEventHandler,
type TextureFreedEventHandler,
Expand Down Expand Up @@ -918,10 +917,7 @@ export class CoreNode extends EventEmitter {
}

// only emit failed outward if we've exhausted all retry attempts
if (
this.texture !== null &&
this.texture.retryCount > this.texture.maxRetryCount
) {
if (this.texture !== null && this.texture.hasExhaustedRetries()) {
this.emit('failed', {
type: 'texture',
error,
Expand Down Expand Up @@ -1473,8 +1469,8 @@ export class CoreNode extends EventEmitter {
if (this.texture !== null) {
// preemptive check for failed textures this will mark the current node as non-renderable
// and will prevent further checks until the texture is reloaded or retry is reset on the texture
if (this.texture.retryCount > this.texture.maxRetryCount) {
// texture has failed to load, we cannot render
if (this.texture.hasExhaustedRetries()) {
// texture has failed to load after all retries, we cannot render
this.updateTextureOwnership(false);
this.setRenderable(false);
return;
Expand Down
39 changes: 39 additions & 0 deletions src/core/CoreTextureManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,7 @@ export class CoreTextureManager extends EventEmitter {
public maxRetryCount: number;
private priorityQueue: Array<Texture> = [];
private uploadTextureQueue: Array<Texture> = [];
private retryQueue: Set<Texture> = new Set();
private initialized = false;
private stage: Stage;
private numImageWorkers: number;
Expand Down Expand Up @@ -316,6 +317,44 @@ export class CoreTextureManager extends EventEmitter {
}
}

/**
* Add a texture to the retry queue
*
* @remarks
* Textures in the retry queue will have their `load()` method called
* automatically when their exponential backoff delay has elapsed.
*
* @param texture - The texture to add to retry queue
*/
addToRetryQueue(texture: Texture): void {
this.retryQueue.add(texture);
}

/**
* Remove a texture from the retry queue
*
* @param texture - The texture to remove from retry queue
*/
removeFromRetryQueue(texture: Texture): void {
this.retryQueue.delete(texture);
}

/**
* Process all textures in the retry queue
*
* @remarks
* Called every frame from Stage.updateFrameTime(). Checks each texture
* in the retry queue and calls load() if its exponential backoff delay
* has elapsed. Textures are automatically removed from the queue when
* they are successfully loaded or when they exhaust their retry attempts.
*/
processTextureRetries(): void {
for (const texture of this.retryQueue) {
// load() will check canRetryNow() internally and only proceed if backoff has elapsed

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we should check for retry early on before calling load, else we're just wasting calls.

texture.load();
}
}

/**
* Create a texture
*
Expand Down
3 changes: 3 additions & 0 deletions src/core/Stage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -344,6 +344,9 @@ export class Stage {
this.txManager.frameTime = newFrameTime;
this.txMemManager.frameTime = newFrameTime;

// Process texture retries with exponential backoff
this.txManager.processTextureRetries();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

wouldnt it be better to make this part of processSome to put a time budget on it?


// This event is emitted at the beginning of the frame (before any updates
// or rendering), so no need to to use `stage.queueFrameEvent` here.
this.eventBus.emit('frameTick', {
Expand Down
85 changes: 84 additions & 1 deletion src/core/textures/Texture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,23 @@ export abstract class Texture extends EventEmitter {
public retryCount = 0;
public maxRetryCount: number;

/**
* Frame time when the last retry attempt was made
*
* @remarks
* Used to implement exponential backoff for texture loading retries.
*/
private lastRetryAttemptFrameTime = 0;

/**
* Base delay in milliseconds for exponential backoff retry strategy
*
* @remarks
* Actual delay = BASE_RETRY_DELAY * Math.pow(2, retryCount - 1)
* This ensures: 1st retry after 500ms, 2nd after 1s, 3rd after 2s, etc.
*/
private static readonly BASE_RETRY_DELAY = 500; // milliseconds

/**
* Timestamp when texture was created (for startup grace period)
*/
Expand Down Expand Up @@ -284,6 +301,12 @@ export abstract class Texture extends EventEmitter {
if (oldSize !== newSize && newSize === 1) {
(this.renderable as boolean) = true;
this.onChangeIsRenderable?.(true);

// If the texture previously failed and can still retry, re-enqueue it.
if (this.state === 'failed' && !this.hasExhaustedRetries()) {
this.txManager.addToRetryQueue(this);
}

this.load();
}
} else {
Expand All @@ -296,17 +319,57 @@ export abstract class Texture extends EventEmitter {
(this.renderable as boolean) = false;
this.onChangeIsRenderable?.(false);

// Remove from retry queue when no nodes are using this texture.
this.txManager.removeFromRetryQueue(this);

// note, not doing a cleanup here, cleanup is managed by the Stage/TextureMemoryManager
// when it deems appropriate based on memory pressure
}
}
}

/**
* Check if enough time has passed to attempt a retry using exponential backoff.
*
* @remarks
* Uses exponential backoff formula: BASE_DELAY * Math.pow(2, retryCount - 1)
* This keeps retry attempts throttled within the render loop without setTimeout.
* Frame time is obtained from txManager, which is updated by the renderer.
*
* @returns true if a retry should be attempted, false if still waiting
*/
private canRetryNow(): boolean {
// First attempt always allowed
if (this.retryCount === 0) {
return true;
}

const currentFrameTime = this.txManager.frameTime;
const requiredDelayMs =
Texture.BASE_RETRY_DELAY * Math.pow(2, this.retryCount - 1);
const timeSinceLastAttempt =
currentFrameTime - this.lastRetryAttemptFrameTime;

return timeSinceLastAttempt >= requiredDelayMs;
}

load(): void {
if (this.retryCount > this.maxRetryCount) {
if (
// We've exceeded the max retry count, do not attempt to load again
this.hasExhaustedRetries() === true ||
// Skip loading if no nodes are using this texture (out of bounds/not renderable)
this.renderableOwners.length === 0 ||
// Skip if already loading
this.state === 'loading' ||
// Check if we should wait longer before retrying
this.canRetryNow() === false
) {
if (this.renderableOwners.length === 0) {
this.txManager.removeFromRetryQueue(this);
}
return;
}

this.txManager.loadTexture(this);
}

Expand Down Expand Up @@ -410,6 +473,9 @@ export abstract class Texture extends EventEmitter {
}

payload = this._dimensions;

// Remove from retry queue when successfully loaded
this.txManager.removeFromRetryQueue(this);
} else if (state === 'failed') {
this._error = errorOrDimensions as Error;
payload = this._error;
Expand All @@ -419,6 +485,14 @@ export abstract class Texture extends EventEmitter {
// to determine if we should try loading again
this.retryCount += 1;

this.lastRetryAttemptFrameTime = this.txManager.frameTime;

if (!this.hasExhaustedRetries()) {
this.txManager.addToRetryQueue(this);
} else {
this.txManager.removeFromRetryQueue(this);
}

queueMicrotask(() => {
this.release();
});
Expand All @@ -436,6 +510,15 @@ export abstract class Texture extends EventEmitter {
this.emit(state, payload);
}

/**
* Check if this texture has exhausted all retry attempts.
*
* @returns true if retries are exhausted, false otherwise
*/
hasExhaustedRetries(): boolean {
return this.retryCount > 0 && this.retryCount >= this.maxRetryCount;
}

/**
* Get the texture data for this texture.
*
Expand Down
Loading