const initializeGDevelopJs = require('../../Binaries/embuild/GDevelop.js/libGD.js'); describe('Use-after-free detection (MemoryTracked)', function () { let gd = null; beforeAll(async () => { gd = await initializeGDevelopJs(); }); describe('Untracked class (VectorString)', function () { it('works normally when alive', function () { const vec = new gd.VectorString(); vec.push_back('hello'); expect(vec.size()).toBe(1); expect(vec.at(0)).toBe('hello'); vec.delete(); }); it('throws UseAfterFreeError after delete()', function () { const vec = new gd.VectorString(); vec.push_back('hello'); vec.delete(); expect(() => vec.size()).toThrow(gd.UseAfterFreeError); }); it('includes destruction context in the error after delete()', function () { const vec = new gd.VectorString(); vec.push_back('hello'); vec.delete(); try { vec.size(); fail('Expected UseAfterFreeError'); } catch (e) { expect(e).toBeInstanceOf(gd.UseAfterFreeError); expect(e.useAfterFreeContext).toBeDefined(); expect(e.useAfterFreeContext.timeSinceDestroyedInMs).not.toBe( undefined ); expect(e.useAfterFreeContext.destroyedBy).toContain( 'Object destroyed here' ); expect(e.useAfterFreeContext.destroyedBy).toContain('.delete'); } }); }); describe('Tracked class (Layout) - JS deletion', function () { it('works normally when alive', function () { const layout = new gd.Layout(); layout.setName('TestScene'); expect(layout.getName()).toBe('TestScene'); layout.delete(); }); it('throws UseAfterFreeError after delete()', function () { const layout = new gd.Layout(); layout.setName('TestScene'); layout.delete(); expect(() => layout.getName()).toThrow(gd.UseAfterFreeError); }); it('includes destruction stack and timing in the error for JS deletion', function () { const layout = new gd.Layout(); layout.setName('TestScene'); layout.delete(); try { layout.getName(); fail('Expected UseAfterFreeError'); } catch (e) { expect(e).toBeInstanceOf(gd.UseAfterFreeError); expect(e.message).toContain('destroyed from JavaScript'); expect(e.useAfterFreeContext.timeSinceDestroyedInMs).not.toBe( undefined ); expect(e.useAfterFreeContext.destroyedBy).toContain( 'Object destroyed here' ); expect(e.useAfterFreeContext.destroyedBy).toContain( 'Object destroyed here' ); expect(e.useAfterFreeContext.destroyedBy).toContain('.delete'); } }); }); describe('Tracked class (Layout) - C++ deletion', function () { it('throws UseAfterFreeError when C++ deletes the underlying object', function () { const project = gd.ProjectHelper.createNewGDJSProject(); project.insertNewLayout('MyScene', 0); // Get a reference to the layout. const layout = project.getLayout('MyScene'); expect(layout.getName()).toBe('MyScene'); // C++ deletes the layout when we remove it from the project. project.removeLayout('MyScene'); // The JS wrapper still has a non-zero ptr, but the object is dead. expect(() => layout.getName()).toThrow(gd.UseAfterFreeError); project.delete(); }); it('includes destruction context and last successful call in error for C++ deletion', function () { const project = gd.ProjectHelper.createNewGDJSProject(); project.insertNewLayout('MyScene', 0); const layout = project.getLayout('MyScene'); // Call a few methods layout.setName('RenamedScene'); expect(layout.getName()).toBe('RenamedScene'); layout.setName('RenamedScene'); // C++ deletes the layout — the call context "Project.removeLayout" // is captured by the ring buffer in MemoryTrackedRegistry. project.removeLayout('RenamedScene'); try { layout.getName(); fail('Expected UseAfterFreeError'); } catch (e) { expect(e).toBeInstanceOf(gd.UseAfterFreeError); expect(e.message).toContain('destroyed on C++ side'); // The call that triggered the destruction. expect(e.useAfterFreeContext.destroyedByCallTo).toContain( 'Project.removeLayout' ); // Timestamp should be present. expect(e.useAfterFreeContext.timeSinceDestroyedInMs).not.toBe( undefined ); // Last successful call on the stale wrapper. expect(e.useAfterFreeContext.lastSuccessfulCallToThisWrapper).toBe( 'Layout.setName' ); } project.delete(); }); it('includes destruction context when a project deletion kills child layouts', function () { const project = gd.ProjectHelper.createNewGDJSProject(); project.insertNewLayout('ChildScene', 0); const layout = project.getLayout('ChildScene'); expect(layout.getName()).toBe('ChildScene'); // Deleting the project destroys all child layouts via the C++ // destructor chain. The delete() wrapper sets the call-context ID // to "Project.delete" before calling gd.destroy(), so the ring // buffer correctly attributes the child destructions. project.delete(); try { layout.getName(); fail('Expected UseAfterFreeError'); } catch (e) { expect(e).toBeInstanceOf(gd.UseAfterFreeError); expect(e.message).toContain('destroyed on C++ side'); // The call that triggered the destruction. expect(e.useAfterFreeContext.destroyedByCallTo).toContain( 'Project.delete' ); // Timestamp should be present. expect(e.useAfterFreeContext.timeSinceDestroyedInMs).not.toBe( undefined ); // Last successful call on the stale wrapper. expect(e.useAfterFreeContext.lastSuccessfulCallToThisWrapper).toBe( 'Layout.getName' ); } }); }); describe('MemoryTrackedRegistry API', function () { it('reports dead objects correctly', function () { const count = gd.MemoryTrackedRegistry.getDeadCount(); expect(typeof count).toBe('number'); }); it('pruneDead clears dead set when over limit', function () { const countBefore = gd.MemoryTrackedRegistry.getDeadCount(); // Pruning with a high limit should not clear. gd.MemoryTrackedRegistry.pruneDead(countBefore + 1000); expect(gd.MemoryTrackedRegistry.getDeadCount()).toBe(countBefore); // Pruning with 0 should clear everything. gd.MemoryTrackedRegistry.pruneDead(0); expect(gd.MemoryTrackedRegistry.getDeadCount()).toBe(0); }); }); describe('Per-class stats', function () { it('reports per-class alive and dead counts', function () { const aliveBefore = gd.MemoryTrackedRegistry.getAliveCountForClass('Layout'); const layout1 = new gd.Layout(); const layout2 = new gd.Layout(); expect(gd.MemoryTrackedRegistry.getAliveCountForClass('Layout')).toBe( aliveBefore + 2 ); layout1.delete(); expect(gd.MemoryTrackedRegistry.getAliveCountForClass('Layout')).toBe( aliveBefore + 1 ); expect( gd.MemoryTrackedRegistry.getDeadCountForClass('Layout') ).toBeGreaterThan(0); layout2.delete(); expect(gd.MemoryTrackedRegistry.getAliveCountForClass('Layout')).toBe( aliveBefore ); }); it('returns 0 for unknown classes', function () { expect( gd.MemoryTrackedRegistry.getAliveCountForClass('NonExistent') ).toBe(0); expect(gd.MemoryTrackedRegistry.getDeadCountForClass('NonExistent')).toBe( 0 ); }); it('returns totals when given empty string', function () { const totalAlive = gd.MemoryTrackedRegistry.getAliveCount(); expect(totalAlive).toBeGreaterThan(0); const totalDead = gd.MemoryTrackedRegistry.getDeadCount(); expect(typeof totalDead).toBe('number'); }); it('tracks different classes independently', function () { const layoutAliveBefore = gd.MemoryTrackedRegistry.getAliveCountForClass('Layout'); const projectAliveBefore = gd.MemoryTrackedRegistry.getAliveCountForClass('Project'); const layout = new gd.Layout(); expect(gd.MemoryTrackedRegistry.getAliveCountForClass('Layout')).toBe( layoutAliveBefore + 1 ); expect(gd.MemoryTrackedRegistry.getAliveCountForClass('Project')).toBe( projectAliveBefore ); layout.delete(); }); }); describe('Tracked class (Project)', function () { it('throws UseAfterFreeError after delete()', function () { const project = gd.ProjectHelper.createNewGDJSProject(); project.setName('Test'); expect(project.getName()).toBe('Test'); project.delete(); expect(() => project.getName()).toThrow(gd.UseAfterFreeError); }); }); describe('Multiple instances with mixed lifetimes', function () { it('independently tracks alive and dead layouts', function () { const project = gd.ProjectHelper.createNewGDJSProject(); project.insertNewLayout('SceneA', 0); project.insertNewLayout('SceneB', 1); project.insertNewLayout('SceneC', 2); const layoutA = project.getLayout('SceneA'); const layoutB = project.getLayout('SceneB'); const layoutC = project.getLayout('SceneC'); // All alive. expect(layoutA.getName()).toBe('SceneA'); expect(layoutB.getName()).toBe('SceneB'); expect(layoutC.getName()).toBe('SceneC'); // Kill only B via C++ deletion. project.removeLayout('SceneB'); // A and C still alive, B is dead. expect(layoutA.getName()).toBe('SceneA'); expect(() => layoutB.getName()).toThrow(gd.UseAfterFreeError); expect(layoutC.getName()).toBe('SceneC'); // Now kill A too. project.removeLayout('SceneA'); expect(() => layoutA.getName()).toThrow(gd.UseAfterFreeError); expect(layoutC.getName()).toBe('SceneC'); // C survives until project deletion. project.delete(); expect(() => layoutC.getName()).toThrow(gd.UseAfterFreeError); }); it('handles interleaved JS and C++ deletions', function () { const project = gd.ProjectHelper.createNewGDJSProject(); // Create a standalone layout (JS-owned) and a project layout (C++-owned). const standaloneLayout = new gd.Layout(); standaloneLayout.setName('Standalone'); project.insertNewLayout('Owned', 0); const ownedLayout = project.getLayout('Owned'); // Both alive. expect(standaloneLayout.getName()).toBe('Standalone'); expect(ownedLayout.getName()).toBe('Owned'); // Delete standalone via JS. standaloneLayout.delete(); expect(() => standaloneLayout.getName()).toThrow(gd.UseAfterFreeError); expect(ownedLayout.getName()).toBe('Owned'); // Delete owned via C++. project.removeLayout('Owned'); expect(() => ownedLayout.getName()).toThrow(gd.UseAfterFreeError); project.delete(); }); it('handles multiple projects with their own layouts', function () { const project1 = gd.ProjectHelper.createNewGDJSProject(); const project2 = gd.ProjectHelper.createNewGDJSProject(); project1.insertNewLayout('Scene1', 0); project2.insertNewLayout('Scene2', 0); const layout1 = project1.getLayout('Scene1'); const layout2 = project2.getLayout('Scene2'); expect(layout1.getName()).toBe('Scene1'); expect(layout2.getName()).toBe('Scene2'); // Delete project1 entirely - layout1 dies, layout2 survives. project1.delete(); expect(() => layout1.getName()).toThrow(gd.UseAfterFreeError); expect(layout2.getName()).toBe('Scene2'); project2.delete(); expect(() => layout2.getName()).toThrow(gd.UseAfterFreeError); }); it('re-adding a layout after removal gives a working fresh reference', function () { const project = gd.ProjectHelper.createNewGDJSProject(); project.insertNewLayout('MyScene', 0); const oldRef = project.getLayout('MyScene'); expect(oldRef.getName()).toBe('MyScene'); // Remove and re-add with the same name. project.removeLayout('MyScene'); project.insertNewLayout('MyScene', 0); const newRef = project.getLayout('MyScene'); // The new reference works fine, even though the name is the same. expect(newRef.getName()).toBe('MyScene'); // Note: oldRef may or may not throw depending on whether the allocator // reused the same address. If it did, add() cleared the dead entry and // the old wrapper happens to point at a valid object (no false positive). // This is a known limitation: we guarantee no false positives, not // detection of every stale reference when addresses are reused. project.delete(); }); it('tracks objects inside layouts independently', function () { const project = gd.ProjectHelper.createNewGDJSProject(); project.insertNewLayout('Scene', 0); const layout = project.getLayout('Scene'); layout.getObjects().insertNewObject(project, 'Sprite', 'Player', 0); layout.getObjects().insertNewObject(project, 'Sprite', 'Enemy', 1); const player = layout.getObjects().getObject('Player'); const enemy = layout.getObjects().getObject('Enemy'); expect(player.getName()).toBe('Player'); expect(enemy.getName()).toBe('Enemy'); // Remove only Player. layout.getObjects().removeObject('Player'); expect(() => player.getName()).toThrow(gd.UseAfterFreeError); expect(enemy.getName()).toBe('Enemy'); // Removing the layout kills the enemy too. project.removeLayout('Scene'); expect(() => enemy.getName()).toThrow(gd.UseAfterFreeError); project.delete(); }); }); describe('Pointer reuse (dead then alive at same address)', function () { it('does not false-positive when a new object reuses a dead address', function () { // We can't control the allocator, but we can create/destroy many // objects to maximize the chance of address reuse, and verify // that no false positive occurs. const project = gd.ProjectHelper.createNewGDJSProject(); for (let round = 0; round < 20; round++) { project.insertNewLayout('TempScene', 0); const layout = project.getLayout('TempScene'); layout.setName('TempScene'); expect(layout.getName()).toBe('TempScene'); project.removeLayout('TempScene'); // Old reference is dead. expect(() => layout.getName()).toThrow(gd.UseAfterFreeError); } // Project itself should still be alive after all that. expect(project.getLayoutsCount()).toBe(0); project.delete(); }); it('handles rapid create/destroy of standalone tracked objects', function () { const deadRefs = []; for (let i = 0; i < 50; i++) { const layout = new gd.Layout(); layout.setName('Layout_' + i); expect(layout.getName()).toBe('Layout_' + i); layout.delete(); deadRefs.push(layout); } // All should be detected as dead. for (const ref of deadRefs) { expect(() => ref.getName()).toThrow(gd.UseAfterFreeError); } // New objects created after should work fine. const alive = new gd.Layout(); alive.setName('StillAlive'); expect(alive.getName()).toBe('StillAlive'); alive.delete(); }); }); describe('Stress test', function () { it('handles many layouts created and destroyed in order', function () { const project = gd.ProjectHelper.createNewGDJSProject(); const COUNT = 100; // Create many layouts. for (let i = 0; i < COUNT; i++) { project.insertNewLayout('Scene_' + i, i); } expect(project.getLayoutsCount()).toBe(COUNT); // Grab references to all. const refs = []; for (let i = 0; i < COUNT; i++) { refs.push(project.getLayout('Scene_' + i)); } // Verify all alive. for (let i = 0; i < COUNT; i++) { expect(refs[i].getName()).toBe('Scene_' + i); } // Remove them in forward order. for (let i = 0; i < COUNT; i++) { project.removeLayout('Scene_' + i); } // All references should be dead. for (let i = 0; i < COUNT; i++) { expect(() => refs[i].getName()).toThrow(gd.UseAfterFreeError); } project.delete(); }); it('handles many layouts created and destroyed in reverse order', function () { const project = gd.ProjectHelper.createNewGDJSProject(); const COUNT = 100; for (let i = 0; i < COUNT; i++) { project.insertNewLayout('Scene_' + i, i); } const refs = []; for (let i = 0; i < COUNT; i++) { refs.push(project.getLayout('Scene_' + i)); } // Remove in reverse order. for (let i = COUNT - 1; i >= 0; i--) { // Verify still alive before removal. expect(refs[i].getName()).toBe('Scene_' + i); project.removeLayout('Scene_' + i); expect(() => refs[i].getName()).toThrow(gd.UseAfterFreeError); } project.delete(); }); it('handles interleaved create/destroy cycles', function () { const project = gd.ProjectHelper.createNewGDJSProject(); const CYCLES = 50; const allDeadRefs = []; for (let cycle = 0; cycle < CYCLES; cycle++) { const name = 'Cycle_' + cycle; project.insertNewLayout(name, 0); const layout = project.getLayout(name); expect(layout.getName()).toBe(name); // Every other cycle, also add and remove an object. if (cycle % 2 === 0) { layout .getObjects() .insertNewObject(project, 'Sprite', 'Obj_' + cycle, 0); const obj = layout.getObjects().getObject('Obj_' + cycle); expect(obj.getName()).toBe('Obj_' + cycle); } project.removeLayout(name); allDeadRefs.push(layout); expect(() => layout.getName()).toThrow(gd.UseAfterFreeError); } // Verify all accumulated dead refs are still detected. for (const ref of allDeadRefs) { expect(() => ref.getName()).toThrow(gd.UseAfterFreeError); } project.delete(); }); it('handles many standalone objects created and destroyed', function () { const COUNT = 200; const alive = []; const dead = []; // Create many, keep half alive. for (let i = 0; i < COUNT; i++) { const layout = new gd.Layout(); layout.setName('L' + i); if (i % 2 === 0) { layout.delete(); dead.push(layout); } else { alive.push(layout); } } // Verify alive ones work. for (const ref of alive) { expect(typeof ref.getName()).toBe('string'); } // Verify dead ones throw. for (const ref of dead) { expect(() => ref.getName()).toThrow(gd.UseAfterFreeError); } // Clean up alive ones. for (const ref of alive) { ref.delete(); } // Now they're dead too. for (const ref of alive) { expect(() => ref.getName()).toThrow(gd.UseAfterFreeError); } }); it('survives rapid create/destroy of projects with layouts', function () { const PROJECTS = 20; const LAYOUTS_PER = 10; for (let p = 0; p < PROJECTS; p++) { const project = gd.ProjectHelper.createNewGDJSProject(); const layoutRefs = []; for (let l = 0; l < LAYOUTS_PER; l++) { const name = 'P' + p + '_L' + l; project.insertNewLayout(name, l); layoutRefs.push(project.getLayout(name)); } // All alive. for (let l = 0; l < LAYOUTS_PER; l++) { expect(layoutRefs[l].getName()).toBe('P' + p + '_L' + l); } // Delete project (kills all layouts). project.delete(); // All layout refs dead. for (const ref of layoutRefs) { expect(() => ref.getName()).toThrow(gd.UseAfterFreeError); } // Project itself is dead. expect(() => project.getName()).toThrow(gd.UseAfterFreeError); } }); it('pruneDead does not cause false positives', function () { const project = gd.ProjectHelper.createNewGDJSProject(); // Create and destroy some layouts to populate the dead set. for (let i = 0; i < 10; i++) { project.insertNewLayout('Prunable_' + i, 0); project.removeLayout('Prunable_' + i); } expect(gd.MemoryTrackedRegistry.getDeadCount()).toBeGreaterThan(0); // Prune the dead set. gd.MemoryTrackedRegistry.pruneDead(0); expect(gd.MemoryTrackedRegistry.getDeadCount()).toBe(0); // Living objects must still work — no false positives. project.insertNewLayout('Alive', 0); const alive = project.getLayout('Alive'); expect(alive.getName()).toBe('Alive'); project.delete(); }); }); });