fix(agentworld): reliably boot Tiny Place world renderer + error/retry (#4038) (#4060)

This commit is contained in:
oxoxDev
2026-06-24 10:09:37 -07:00
committed by GitHub
parent 689ef8123c
commit 854df03363
18 changed files with 272 additions and 17 deletions
+81 -11
View File
@@ -8,6 +8,7 @@
* container that is scaled to fill its parent, so the world stays crisp pixel
* art at any size.
*/
import debugFactory from 'debug';
import {
Application,
BitmapFont,
@@ -35,6 +36,26 @@ import { ROOM_REGISTRY, type RoomEntry } from './rooms';
import { TextureFactory } from './textures';
import { type AgentState, type ChatMessage, TileCode, type WalkNode } from './types';
const debug = debugFactory('agentworld:gameworld');
// The CEF/Chromium renderer used by the desktop shell does not reliably expose a
// WebGPU adapter — `Application.init({ preference: 'webgpu' })` can wait forever
// on adapter acquisition and never resolve, leaving the world stuck on "Booting
// renderer...". WebGL is the dependable path in Chromium/CEF, so we request it
// directly instead of letting Pixi probe (and hang on) WebGPU first.
const RENDERER_PREFERENCE = 'webgl' as const;
// Backstop in case even WebGL context creation wedges: race init against this so
// the caller can surface an error + retry rather than spinning indefinitely.
const INIT_TIMEOUT_MS = 10_000;
/** Thrown when renderer init rejects or exceeds {@link INIT_TIMEOUT_MS}. */
export class RendererInitError extends Error {
public constructor(message: string, options?: { cause?: unknown }) {
super(message, options);
this.name = 'RendererInitError';
}
}
const NAMEPLATE_FONT = 'iso-body';
const BUBBLE_FONT = 'iso-bubble';
const FONT_CHARACTERS: Array<Array<string> | string> = [
@@ -217,22 +238,36 @@ export class GameWorld {
public async init(parent: HTMLElement): Promise<void> {
if (this.app) {
debug('init skipped: renderer already initialized');
return;
}
debug('init start preference=%s timeoutMs=%d', RENDERER_PREFERENCE, INIT_TIMEOUT_MS);
TextureSource.defaultOptions.scaleMode = 'nearest';
// Build the Application locally first; only commit it to `this.app` once init
// fully succeeds, so a timeout/failure leaves GameWorld in its pristine
// pre-init state and `init()` can be retried cleanly.
const app = new Application();
await app.init({
width: NATIVE_RESOLUTION,
height: NATIVE_RESOLUTION,
antialias: true,
preference: 'webgpu',
powerPreference: 'high-performance',
resolution: globalThis.devicePixelRatio || 1,
autoDensity: true,
backgroundAlpha: 1,
background: 0x0c0f16,
});
try {
await this.runInitWithTimeout(app);
debug('renderer init resolved preference=%s', RENDERER_PREFERENCE);
} catch (error: unknown) {
// Tear down any partially-created GPU/canvas resources so a retry starts
// from a clean slate (and we don't leak a dangling renderer).
try {
app.destroy?.(true, { children: true, texture: true });
} catch (destroyError: unknown) {
debug('init cleanup: destroy after failure threw: %s', String(destroyError));
}
this.app = null;
this.factory = null;
const message = error instanceof Error ? error.message : String(error);
debug('renderer init failed: %s', message);
throw error instanceof RendererInitError
? error
: new RendererInitError(`renderer init failed: ${message}`, { cause: error });
}
this.app = app;
this.factory = new TextureFactory(app.renderer);
@@ -258,6 +293,41 @@ export class GameWorld {
app.ticker.add(this.tick);
}
/**
* Race `Application.init` against {@link INIT_TIMEOUT_MS}. Resolves when the
* renderer is ready; rejects with a {@link RendererInitError} if init rejects
* or the timeout fires first (the CEF WebGPU-adapter hang, primarily).
*/
private async runInitWithTimeout(app: Application): Promise<void> {
let timer: ReturnType<typeof setTimeout> | undefined;
const timeout = new Promise<never>((_resolve, reject) => {
timer = setTimeout(() => {
debug('renderer init timed out after %dms', INIT_TIMEOUT_MS);
reject(new RendererInitError(`renderer init timed out after ${INIT_TIMEOUT_MS}ms`));
}, INIT_TIMEOUT_MS);
});
const initialize = app.init({
width: NATIVE_RESOLUTION,
height: NATIVE_RESOLUTION,
antialias: true,
preference: RENDERER_PREFERENCE,
powerPreference: 'high-performance',
resolution: globalThis.devicePixelRatio || 1,
autoDensity: true,
backgroundAlpha: 1,
background: 0x0c0f16,
});
try {
await Promise.race([initialize, timeout]);
} finally {
if (timer !== undefined) {
clearTimeout(timer);
}
}
}
private installFonts(): void {
if (fontsInstalled) {
return;
+1 -1
View File
@@ -1,6 +1,6 @@
/** Public surface of the isometric agent world engine. */
export { GameWorld } from './GameWorld';
export { GameWorld, RendererInitError } from './GameWorld';
export type { AgentSummary } from './GameWorld';
export { BaseRoom } from './BaseRoom';
export {
@@ -0,0 +1,105 @@
/**
* Tests for WorldSection — the "Tiny Place" World tab renderer boot UX.
*
* Regression coverage for #4038: the PixiJS renderer init promise could reject
* or hang forever (CEF WebGPU adapter never settling), leaving a permanent
* silent "Booting renderer..." overlay with no error and no retry. These tests
* assert that a rejected init surfaces an error overlay + Retry button, and that
* Retry re-runs init (recovering to the ready state on a subsequent success).
*
* The iso engine is mocked at module level — no real WebGL/WebGPU is touched.
*/
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { beforeEach, describe, expect, test, vi } from 'vitest';
import WorldSection from './WorldSection';
// Controllable GameWorld test double. `initImpl` is swapped per-test so we can
// drive resolve / reject / never-settle behavior.
let initImpl: (parent: HTMLElement) => Promise<void>;
const initSpy = vi.fn((parent: HTMLElement) => initImpl(parent));
const setChangeListener = vi.fn();
const setRoom = vi.fn();
const spawnAgents = vi.fn();
const setAutonomous = vi.fn();
const destroy = vi.fn();
vi.mock('../iso', () => {
class FakeGameWorld {
public currentRoomKey = 'outside';
public init = (parent: HTMLElement): Promise<void> => initSpy(parent);
public setChangeListener = setChangeListener;
public setRoom = setRoom;
public spawnAgents = spawnAgents;
public setAutonomous = setAutonomous;
public destroy = destroy;
}
return {
GameWorld: FakeGameWorld,
ROOM_REGISTRY: [
{ key: 'outside', name: 'World', description: 'A large open plaza.' },
{ key: 'poker', name: 'Poker', description: 'Eight seats around a felt table.' },
],
RendererInitError: class RendererInitError extends Error {},
};
});
beforeEach(() => {
vi.clearAllMocks();
initImpl = () => Promise.resolve();
});
describe('WorldSection renderer boot', () => {
test('shows booting overlay while init is in flight', () => {
initImpl = () => new Promise<void>(() => {}); // never settles
render(<WorldSection />);
expect(screen.getByText(/booting renderer/i)).toBeInTheDocument();
expect(screen.queryByRole('button', { name: /retry/i })).not.toBeInTheDocument();
});
test('hides booting overlay and wires the world once init resolves', async () => {
initImpl = () => Promise.resolve();
render(<WorldSection />);
await waitFor(() => {
expect(screen.queryByText(/booting renderer/i)).not.toBeInTheDocument();
});
expect(setRoom).toHaveBeenCalledWith('outside');
expect(spawnAgents).toHaveBeenCalled();
expect(setAutonomous).toHaveBeenCalledWith(true);
expect(screen.queryByRole('button', { name: /retry/i })).not.toBeInTheDocument();
});
test('shows error overlay + Retry when init rejects', async () => {
initImpl = () => Promise.reject(new Error('webgpu adapter hung'));
render(<WorldSection />);
await waitFor(() => {
expect(screen.getByText(/could not start the world renderer/i)).toBeInTheDocument();
});
expect(screen.getByRole('button', { name: /retry/i })).toBeInTheDocument();
// The dead-end booting overlay must NOT also be showing.
expect(screen.queryByText(/booting renderer/i)).not.toBeInTheDocument();
});
test('Retry re-invokes init and recovers to ready on success', async () => {
const user = userEvent.setup();
// First attempt fails, second succeeds.
initImpl = () => Promise.reject(new Error('init failed'));
render(<WorldSection />);
await waitFor(() => {
expect(screen.getByRole('button', { name: /retry/i })).toBeInTheDocument();
});
expect(initSpy).toHaveBeenCalledTimes(1);
initImpl = () => Promise.resolve();
await user.click(screen.getByRole('button', { name: /retry/i }));
await waitFor(() => {
expect(initSpy).toHaveBeenCalledTimes(2);
});
await waitFor(() => {
expect(screen.queryByText(/could not start the world renderer/i)).not.toBeInTheDocument();
});
expect(setRoom).toHaveBeenCalledWith('outside');
});
});
+57 -5
View File
@@ -9,6 +9,11 @@ const debug = debugFactory('agentworld:world');
const WORLD_ROOM_KEY = 'outside';
const WORLD_POPULATION = 100;
const ROOM_POPULATION = 8;
// Defense-in-depth backstop: even though GameWorld.init() already races its own
// timeout, guard against the (rare) case where the init promise never settles at
// all — flip to an error state so the user always gets a Retry instead of an
// indefinite "Booting renderer..." overlay.
const BOOT_TIMEOUT_MS = 15_000;
const populationFor = (key: string): number =>
key === WORLD_ROOM_KEY ? WORLD_POPULATION : ROOM_POPULATION;
@@ -25,7 +30,10 @@ export default function WorldSection() {
const containerRef = useRef<HTMLDivElement>(null);
const worldRef = useRef<GameWorld | null>(null);
const [ready, setReady] = useState(false);
const [error, setError] = useState<string | null>(null);
const [roomKey, setRoomKey] = useState(WORLD_ROOM_KEY);
// Bumped by the Retry button to re-run the init effect from scratch.
const [retryNonce, setRetryNonce] = useState(0);
useEffect(() => {
const container = containerRef.current;
@@ -34,14 +42,35 @@ export default function WorldSection() {
return;
}
debug('mounting pixi world');
debug('mounting pixi world attempt=%d', retryNonce);
// Reset visible state for this (possibly retried) boot attempt.
setReady(false);
setError(null);
const world = new GameWorld();
worldRef.current = world;
let disposed = false;
// Component-level backstop in case init() never settles (belt-and-braces
// over GameWorld's own internal timeout).
let bootTimer: ReturnType<typeof setTimeout> | undefined = setTimeout(() => {
if (disposed) {
return;
}
debug('renderer boot backstop fired after %dms', BOOT_TIMEOUT_MS);
setError(t('agentWorld.world.initError', 'Could not start the world renderer.'));
}, BOOT_TIMEOUT_MS);
const clearBootTimer = (): void => {
if (bootTimer !== undefined) {
clearTimeout(bootTimer);
bootTimer = undefined;
}
};
void world
.init(container)
.then(() => {
clearBootTimer();
if (disposed) {
debug('renderer initialized after unmount; destroying stale world');
world.destroy();
@@ -56,18 +85,31 @@ export default function WorldSection() {
setReady(true);
debug('renderer ready room=%s population=%d', WORLD_ROOM_KEY, WORLD_POPULATION);
})
.catch((error: unknown) => {
debug('renderer init failed: %s', String(error));
.catch((initError: unknown) => {
clearBootTimer();
debug('renderer init failed: %s', String(initError));
if (disposed) {
return;
}
setError(t('agentWorld.world.initError', 'Could not start the world renderer.'));
});
return () => {
debug('unmounting pixi world');
disposed = true;
clearBootTimer();
world.setChangeListener(null);
world.destroy();
worldRef.current = null;
};
}, []);
// `t` is stable enough for messaging; retryNonce re-runs the whole boot.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [retryNonce]);
const handleRetry = (): void => {
debug('retry requested');
setRetryNonce(nonce => nonce + 1);
};
const handleRoom = (key: string): void => {
const world = worldRef.current;
@@ -88,7 +130,17 @@ export default function WorldSection() {
return (
<div className="relative h-full w-full overflow-hidden bg-black">
<div ref={containerRef} className="absolute inset-0" />
{ready ? null : (
{error ? (
<div className="absolute inset-0 z-20 flex flex-col items-center justify-center gap-4 px-6 text-center">
<p className="max-w-sm text-sm text-neutral-200">{error}</p>
<button
type="button"
className="rounded-lg border border-primary-500 bg-primary-500 px-4 py-2 text-sm font-medium text-white transition hover:bg-primary-600 dark:border-primary-500 dark:bg-primary-600"
onClick={handleRetry}>
{t('agentWorld.world.retry', 'Retry')}
</button>
</div>
) : ready ? null : (
<div className="absolute inset-0 flex items-center justify-center text-sm text-neutral-300">
{t('agentWorld.world.booting', 'Booting renderer...')}
</div>
+2
View File
@@ -83,6 +83,8 @@ const messages: TranslationMap = {
'Tiny.Place شبكة اجتماعية لوكلاء الذكاء الاصطناعي. استخدم OpenHuman للتفاعل والعثور على الوظائف ونشرها والتداول والنمو معًا.',
'agentWorld.world': 'العالم',
'agentWorld.world.booting': 'جارٍ تشغيل العارض...',
'agentWorld.world.initError': 'تعذّر تشغيل عارض العالم.',
'agentWorld.world.retry': 'إعادة المحاولة',
'agentWorld.world.title': 'Tiny Place',
'agentWorld.world.description':
'انضم إلى tiny.place ليتمكن وكيلك من التنسيق مع الوكلاء الآخرين: العثور على الوظائف ونشرها، والتداول، وتبادل الرسائل، والتعاون في المكافآت.',
+2
View File
@@ -84,6 +84,8 @@ const messages: TranslationMap = {
'Tiny.Place হলো এআই এজেন্টদের জন্য একটি সোশ্যাল নেটওয়ার্ক। যোগাযোগ করতে, কাজ খুঁজতে ও পোস্ট করতে, লেনদেন করতে এবং একসাথে বেড়ে উঠতে OpenHuman ব্যবহার করুন।',
'agentWorld.world': 'বিশ্ব',
'agentWorld.world.booting': 'রেন্ডারার চালু হচ্ছে...',
'agentWorld.world.initError': 'ওয়ার্ল্ড রেন্ডারার চালু করা যায়নি।',
'agentWorld.world.retry': 'আবার চেষ্টা করুন',
'agentWorld.world.title': 'Tiny Place',
'agentWorld.world.description':
'tiny.place-এ যোগ দিন যাতে আপনার এজেন্ট অন্য এজেন্টদের সাথে সমন্বয় করতে পারে — কাজ খুঁজে পাওয়া ও পোস্ট করা, লেনদেন, বার্তা পাঠানো এবং বাউন্টিতে একসাথে কাজ করা।',
+2
View File
@@ -85,6 +85,8 @@ const messages: TranslationMap = {
'Tiny.Place ist ein soziales Netzwerk für KI-Agenten. Nutze OpenHuman, um zu interagieren, Jobs zu finden und zu veröffentlichen, zu handeln und gemeinsam zu wachsen.',
'agentWorld.world': 'Welt',
'agentWorld.world.booting': 'Renderer wird gestartet...',
'agentWorld.world.initError': 'Der Welt-Renderer konnte nicht gestartet werden.',
'agentWorld.world.retry': 'Wiederholen',
'agentWorld.world.title': 'Tiny Place',
'agentWorld.world.description':
'Tritt tiny.place bei, damit dein Agent sich mit anderen Agenten abstimmen kann: Jobs finden und ausschreiben, handeln, Nachrichten senden und bei Bounties zusammenarbeiten.',
+2
View File
@@ -30,6 +30,8 @@ const en: TranslationMap = {
'Tiny.Place is a social network for AI agents. Use OpenHuman to interact, find and post jobs, trade, and grow together.',
'agentWorld.world': 'World',
'agentWorld.world.booting': 'Booting renderer...',
'agentWorld.world.initError': 'Could not start the world renderer.',
'agentWorld.world.retry': 'Retry',
'agentWorld.world.title': 'Tiny Place',
'agentWorld.world.description':
'Join tiny.place so your agent can coordinate with other agents — find and post jobs, trade, message, and team up on bounties.',
+2
View File
@@ -84,6 +84,8 @@ const messages: TranslationMap = {
'Tiny.Place es una red social para agentes de IA. Usa OpenHuman para interactuar, encontrar y publicar trabajos, comerciar y crecer juntos.',
'agentWorld.world': 'Mundo',
'agentWorld.world.booting': 'Iniciando renderizador...',
'agentWorld.world.initError': 'No se pudo iniciar el renderizador del mundo.',
'agentWorld.world.retry': 'Reintentar',
'agentWorld.world.title': 'Tiny Place',
'agentWorld.world.description':
'Únete a tiny.place para que tu agente coordine con otros agentes: encontrar y publicar trabajos, comerciar, enviar mensajes y colaborar en recompensas.',
+2
View File
@@ -84,6 +84,8 @@ const messages: TranslationMap = {
'Tiny.Place est un réseau social pour les agents IA. Utilisez OpenHuman pour interagir, trouver et publier des missions, échanger et grandir ensemble.',
'agentWorld.world': 'Monde',
'agentWorld.world.booting': 'Démarrage du moteur de rendu...',
'agentWorld.world.initError': 'Impossible de démarrer le moteur de rendu du monde.',
'agentWorld.world.retry': 'Réessayer',
'agentWorld.world.title': 'Tiny Place',
'agentWorld.world.description':
'Rejoignez tiny.place pour que votre agent collabore avec dautres agents : trouver et publier des missions, échanger, discuter et coopérer sur des primes.',
+2
View File
@@ -84,6 +84,8 @@ const messages: TranslationMap = {
'Tiny.Place एआई एजेंट्स के लिए एक सोशल नेटवर्क है। बातचीत करने, काम खोजने और पोस्ट करने, व्यापार करने और साथ मिलकर आगे बढ़ने के लिए OpenHuman का उपयोग करें।',
'agentWorld.world': 'दुनिया',
'agentWorld.world.booting': 'रेंडरर शुरू हो रहा है...',
'agentWorld.world.initError': 'वर्ल्ड रेंडरर शुरू नहीं किया जा सका।',
'agentWorld.world.retry': 'पुनः प्रयास करें',
'agentWorld.world.title': 'Tiny Place',
'agentWorld.world.description':
'tiny.place से जुड़ें ताकि आपका एजेंट दूसरे एजेंट्स के साथ तालमेल कर सके — काम ढूँढना और पोस्ट करना, व्यापार करना, संदेश भेजना और बाउंटी पर मिलकर काम करना।',
+2
View File
@@ -83,6 +83,8 @@ const messages: TranslationMap = {
'Tiny.Place adalah jejaring sosial untuk agen AI. Gunakan OpenHuman untuk berinteraksi, menemukan dan memasang pekerjaan, berdagang, serta tumbuh bersama.',
'agentWorld.world': 'Dunia',
'agentWorld.world.booting': 'Memulai perender...',
'agentWorld.world.initError': 'Tidak dapat memulai perender dunia.',
'agentWorld.world.retry': 'Coba lagi',
'agentWorld.world.title': 'Tiny Place',
'agentWorld.world.description':
'Bergabunglah dengan tiny.place agar agen Anda dapat berkoordinasi dengan agen lain: menemukan dan memposting pekerjaan, berdagang, berkirim pesan, dan bekerja sama dalam bounty.',
+2
View File
@@ -84,6 +84,8 @@ const messages: TranslationMap = {
'Tiny.Place è un social network per agenti IA. Usa OpenHuman per interagire, trovare e pubblicare lavori, scambiare e crescere insieme.',
'agentWorld.world': 'Mondo',
'agentWorld.world.booting': 'Avvio del renderer...',
'agentWorld.world.initError': 'Impossibile avviare il renderer del mondo.',
'agentWorld.world.retry': 'Riprova',
'agentWorld.world.title': 'Tiny Place',
'agentWorld.world.description':
'Unisciti a tiny.place per far coordinare il tuo agente con altri agenti: trovare e pubblicare lavori, scambiare, messaggiare e collaborare alle taglie.',
+2
View File
@@ -84,6 +84,8 @@ const messages: TranslationMap = {
'Tiny.Place는 AI 에이전트를 위한 소셜 네트워크입니다. OpenHuman을 사용해 소통하고, 일자리를 찾고 올리며, 거래하고 함께 성장하세요.',
'agentWorld.world': '월드',
'agentWorld.world.booting': '렌더러를 시작하는 중...',
'agentWorld.world.initError': '월드 렌더러를 시작할 수 없습니다.',
'agentWorld.world.retry': '다시 시도',
'agentWorld.world.title': 'Tiny Place',
'agentWorld.world.description':
'tiny.place에 참여하면 에이전트가 다른 에이전트와 협업할 수 있습니다: 작업을 찾고 게시하고, 거래하고, 메시지를 주고받고, 바운티에 함께 참여하세요.',
+2
View File
@@ -84,6 +84,8 @@ const messages: TranslationMap = {
'Tiny.Place to sieć społecznościowa dla agentów AI. Używaj OpenHuman, aby wchodzić w interakcje, znajdować i publikować zlecenia, handlować i wspólnie się rozwijać.',
'agentWorld.world': 'Świat',
'agentWorld.world.booting': 'Uruchamianie renderera...',
'agentWorld.world.initError': 'Nie udało się uruchomić renderera świata.',
'agentWorld.world.retry': 'Ponów',
'agentWorld.world.title': 'Tiny Place',
'agentWorld.world.description':
'Dołącz do tiny.place, aby Twój agent współpracował z innymi agentami: znajdował i publikował zlecenia, handlował, wysyłał wiadomości i działał przy nagrodach.',
+2
View File
@@ -84,6 +84,8 @@ const messages: TranslationMap = {
'Tiny.Place é uma rede social para agentes de IA. Use o OpenHuman para interagir, encontrar e publicar trabalhos, negociar e crescer juntos.',
'agentWorld.world': 'Mundo',
'agentWorld.world.booting': 'Iniciando renderizador...',
'agentWorld.world.initError': 'Não foi possível iniciar o renderizador do mundo.',
'agentWorld.world.retry': 'Tentar novamente',
'agentWorld.world.title': 'Tiny Place',
'agentWorld.world.description':
'Junte-se ao tiny.place para que seu agente coordene com outros agentes: encontrar e publicar trabalhos, negociar, enviar mensagens e colaborar em recompensas.',
+2
View File
@@ -84,6 +84,8 @@ const messages: TranslationMap = {
'Tiny.Place — это социальная сеть для ИИ-агентов. Используйте OpenHuman, чтобы взаимодействовать, находить и публиковать задания, торговать и расти вместе.',
'agentWorld.world': 'Мир',
'agentWorld.world.booting': 'Запуск рендерера...',
'agentWorld.world.initError': 'Не удалось запустить рендерер мира.',
'agentWorld.world.retry': 'Повторить',
'agentWorld.world.title': 'Tiny Place',
'agentWorld.world.description':
'Присоединяйтесь к tiny.place, чтобы ваш агент взаимодействовал с другими агентами: находил и публиковал задания, торговал, обменивался сообщениями и работал над наградами.',
+2
View File
@@ -83,6 +83,8 @@ const messages: TranslationMap = {
'Tiny.Place 是面向 AI 智能体的社交网络。使用 OpenHuman 来互动、查找和发布工作、交易并共同成长。',
'agentWorld.world': '世界',
'agentWorld.world.booting': '正在启动渲染器...',
'agentWorld.world.initError': '无法启动世界渲染器。',
'agentWorld.world.retry': '重试',
'agentWorld.world.title': 'Tiny Place',
'agentWorld.world.description':
'加入 tiny.place,让你的代理与其他代理协作:查找和发布任务、交易、收发消息以及共同完成悬赏。',