From d66715f3cb16125b521e51f1c3100ba3d3a00b7b Mon Sep 17 00:00:00 2001 From: Kouji Takao Date: Fri, 2 Jan 2026 16:22:25 +0900 Subject: [PATCH 1/2] fix: prevent stack overflow in RateLimiter when merging data - Use arrays to manage resolve/reject callbacks instead of nesting functions - Optimize logging by using log.debug for high-frequency operations - Added periodic statistics reporting for sent and merged items - Added reproduction test case Addresses https://github.com/smalruby/smalruby3-gui/issues/499 Co-Authored-By: Gemini --- .../scratch3_mesh_v2/rate-limiter.js | 68 +++++++++++++++---- .../scratch3_mesh_v2_rate_limiter_repro.js | 41 +++++++++++ 2 files changed, 94 insertions(+), 15 deletions(-) create mode 100644 test/unit/scratch3_mesh_v2_rate_limiter_repro.js diff --git a/src/extensions/scratch3_mesh_v2/rate-limiter.js b/src/extensions/scratch3_mesh_v2/rate-limiter.js index 008da05f97..401be742ce 100644 --- a/src/extensions/scratch3_mesh_v2/rate-limiter.js +++ b/src/extensions/scratch3_mesh_v2/rate-limiter.js @@ -20,6 +20,13 @@ class RateLimiter { this.enableMerge = options.enableMerge || false; this.mergeKeyField = options.mergeKeyField || 'key'; this.requestCount = 0; + + // Statistics + this.stats = { + totalSent: 0, + totalMerged: 0, + lastReportTime: Date.now() + }; } /** @@ -38,7 +45,7 @@ class RateLimiter { this.queue.push({data, resolve, reject, sendFunction}); } - log.info(`RateLimiter: ${this.enableMerge ? 'Processed' : 'Added'} to queue ` + + log.debug(`RateLimiter: ${this.enableMerge ? 'Processed' : 'Added'} to queue ` + `(size: ${this.queue.length}, enableMerge: ${this.enableMerge})`); this.processQueue(); @@ -63,35 +70,64 @@ class RateLimiter { const existingData = queueItem.data; const mergedData = this.mergeData(existingData, dataArray); - log.info(`RateLimiter: Merging data - ` + + log.debug(`RateLimiter: Merging data - ` + `before: ${JSON.stringify(existingData)}, ` + `after: ${JSON.stringify(mergedData)}`); queueItem.data = mergedData; - // Chain resolve and reject - const originalResolve = queueItem.resolve; - queueItem.resolve = result => { - originalResolve(result); - resolve(result); - }; + // Use arrays to manage resolve/reject callbacks to avoid stack overflow + if (!queueItem.resolvers) { + // Convert existing resolve/reject to arrays + queueItem.resolvers = [queueItem.resolve]; + queueItem.rejecters = [queueItem.reject]; + + // Set new resolve/reject handlers that call all functions in the arrays + queueItem.resolve = result => { + queueItem.resolvers.forEach(r => r(result)); + }; + queueItem.reject = error => { + queueItem.rejecters.forEach(r => r(error)); + }; + } - const originalReject = queueItem.reject; - queueItem.reject = error => { - originalReject(error); - reject(error); - }; + // Add new callbacks to the arrays + queueItem.resolvers.push(resolve); + queueItem.rejecters.push(reject); merged = true; break; } } - if (!merged) { + if (merged) { + this.stats.totalMerged++; + this.reportStatsIfNeeded(); + } else { this.queue.push({data: dataArray, resolve, reject, sendFunction}); } } + /** + * Report statistics periodically. + * @private + */ + reportStatsIfNeeded () { + const now = Date.now(); + const elapsed = now - this.stats.lastReportTime; + + // Output statistics every 10 seconds + if (elapsed >= 10000) { + log.info(`RateLimiter Stats (last ${(elapsed / 1000).toFixed(1)}s): ` + + `sent=${this.stats.totalSent}, merged=${this.stats.totalMerged}, ` + + `queue=${this.queue.length}`); + + this.stats.totalSent = 0; + this.stats.totalMerged = 0; + this.stats.lastReportTime = now; + } + } + /** * Merge two arrays of data using mergeKeyField. * @param {Array} existingData - Existing data items. @@ -161,12 +197,14 @@ class RateLimiter { const item = this.queue.shift(); this.requestCount++; - log.info(`RateLimiter: Sending request #${this.requestCount} ` + + log.debug(`RateLimiter: Sending request #${this.requestCount} ` + `(queue remaining: ${this.queue.length})`); try { const result = await item.sendFunction(item.data); this.lastSendTime = Date.now(); + this.stats.totalSent++; + this.reportStatsIfNeeded(); item.resolve(result); } catch (error) { item.reject(error); diff --git a/test/unit/scratch3_mesh_v2_rate_limiter_repro.js b/test/unit/scratch3_mesh_v2_rate_limiter_repro.js new file mode 100644 index 0000000000..09d96cecc1 --- /dev/null +++ b/test/unit/scratch3_mesh_v2_rate_limiter_repro.js @@ -0,0 +1,41 @@ +const test = require('tap').test; +const RateLimiter = require('../../src/extensions/scratch3_mesh_v2/rate-limiter'); +const log = require('../../src/util/log'); + +// Disable logging for test to avoid timeout +log.debug = () => {}; +log.info = () => {}; + +test('RateLimiter stack overflow reproduction', {timeout: 60000}, async t => { + // maxPerSecond: 1, intervalMs: 250ms, enableMerge: true + const limiter = new RateLimiter(1, 250, {enableMerge: true}); + + // Immediate sendFunction + const sendFunction = d => Promise.resolve(d); + + const promises = []; + + // 15000 merges is usually enough to trigger stack overflow in Node.js + const MERGE_COUNT = 15000; + + console.log(`Starting ${MERGE_COUNT} merges...`); + + for (let i = 0; i < MERGE_COUNT; i++) { + promises.push(limiter.send([{key: 'var1', value: i}], sendFunction)); + } + + console.log('Finished pushing to queue. Waiting for completion...'); + + try { + await Promise.all(promises); + t.pass('Completed without stack overflow'); + } catch (e) { + if (e.message === 'Maximum call stack size exceeded') { + t.fail(`Stack overflow occurred: ${e.message}`); + } else { + t.fail(`Failed with unexpected error: ${e.message}`); + } + } + + t.end(); +}); From c5a434776ab8d41a827ecef97d90c29291574e96 Mon Sep 17 00:00:00 2001 From: Kouji Takao Date: Fri, 2 Jan 2026 16:26:06 +0900 Subject: [PATCH 2/2] fix: reduce logging frequency in MeshV2Service - Changed log.info to log.debug in sendData to reduce console flooding Co-Authored-By: Gemini --- src/extensions/scratch3_mesh_v2/mesh-service.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/extensions/scratch3_mesh_v2/mesh-service.js b/src/extensions/scratch3_mesh_v2/mesh-service.js index 31e6c8d81f..66df8081ab 100644 --- a/src/extensions/scratch3_mesh_v2/mesh-service.js +++ b/src/extensions/scratch3_mesh_v2/mesh-service.js @@ -765,7 +765,7 @@ class MeshV2Service { if (!this.groupId || !this.client) return; const unchanged = this.isDataUnchanged(dataArray); - log.info(`Mesh V2: sendData called with ${dataArray.length} items: ` + + log.debug(`Mesh V2: sendData called with ${dataArray.length} items: ` + `${JSON.stringify(dataArray)} (unchanged: ${unchanged})`); // Change detection