From d1647dead822a90564792ad096e0ffb7ce628b49 Mon Sep 17 00:00:00 2001 From: SimonShiki Date: Mon, 25 May 2026 11:42:33 +0800 Subject: [PATCH 1/2] :bug: fix(vm): memory leak on _stackFrameFreeList Signed-off-by: SimonShiki --- packages/vm/src/engine/runtime.ts | 4 +++- packages/vm/src/engine/thread.ts | 36 ++++++++++++++++++++++++++++++- 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/packages/vm/src/engine/runtime.ts b/packages/vm/src/engine/runtime.ts index a15814b2..136b1508 100644 --- a/packages/vm/src/engine/runtime.ts +++ b/packages/vm/src/engine/runtime.ts @@ -10,7 +10,7 @@ import Sequencer from './sequencer'; import execute from './execute'; import ScratchBlocksConstants from './scratch-blocks-constants'; import TargetType from '../extension-support/target-type'; -import Thread from './thread'; +import Thread, {clearStackFrameFreeList} from './thread'; import log from '../util/log'; import maybeFormatMessage from '../util/maybe-format-message'; import StageLayering from './stage-layering'; @@ -2099,6 +2099,7 @@ class Runtime extends EventEmitter { }); this.targets.map(this.disposeTarget, this); + clearStackFrameFreeList(); this._monitorState = OrderedMap({}); this.emit(Runtime.RUNTIME_DISPOSED); this.ioDevices.clock.resetProjectTimer(); @@ -2203,6 +2204,7 @@ class Runtime extends EventEmitter { if (disposingTarget !== target) return true; // Allow target to do dispose actions. target.dispose(); + clearStackFrameFreeList(disposingTarget); // Remove from list of targets. return false; }); diff --git a/packages/vm/src/engine/thread.ts b/packages/vm/src/engine/thread.ts index 78542885..780e1dc0 100644 --- a/packages/vm/src/engine/thread.ts +++ b/packages/vm/src/engine/thread.ts @@ -41,10 +41,25 @@ class _StackFrame { * A context passed to block implementations. */ executionContext: unknown = null; + /** + * The target of blocks that this stack frame will execute. + * Stored as a weak reference to make GC happy. + */ + #target: WeakRef | null = null; + /** * The target of blocks that this thread will execute. + * + * Note that the stack frame only holds the weak reference to the target, + * so it may be null if the target has been garbage collected. */ - target: RenderedTarget | null = null; + get target (): RenderedTarget | null { + return this.#target?.deref() ?? null; + } + + set target (value: RenderedTarget | null) { + this.#target = value ? new WeakRef(value) : null; + } /** * @param warpMode Whether this level is in warp mode. Is set by some legacy blocks and @@ -412,6 +427,25 @@ class Thread { } } +/** + * Clear the stack frame recycle bin. + * @param target If provided, only remove frames referencing this target or whose target is no longer alive. + * If falsy, clear the entire free list. + */ +function clearStackFrameFreeList (target?: RenderedTarget): void { + if (!target) { + _stackFrameFreeList.length = 0; + return; + } + for (let i = _stackFrameFreeList.length - 1; i >= 0; --i) { + const frameTarget = _stackFrameFreeList[i].target; + if (!frameTarget || frameTarget === target) { + _stackFrameFreeList.splice(i, 1); + } + } +} + export type {Thread, _StackFrame as ThreadStackFrame}; +export {clearStackFrameFreeList}; export default Thread; From 8920021a556465249c48a599e22d33dde31ab873 Mon Sep 17 00:00:00 2001 From: SimonShiki Date: Mon, 25 May 2026 11:47:32 +0800 Subject: [PATCH 2/2] :wrench: chore(vm): remove unnecessary void Signed-off-by: SimonShiki --- packages/vm/src/engine/thread.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/vm/src/engine/thread.ts b/packages/vm/src/engine/thread.ts index 780e1dc0..c4a8193d 100644 --- a/packages/vm/src/engine/thread.ts +++ b/packages/vm/src/engine/thread.ts @@ -432,7 +432,7 @@ class Thread { * @param target If provided, only remove frames referencing this target or whose target is no longer alive. * If falsy, clear the entire free list. */ -function clearStackFrameFreeList (target?: RenderedTarget): void { +function clearStackFrameFreeList (target?: RenderedTarget) { if (!target) { _stackFrameFreeList.length = 0; return;