From 59f8d7499e5c70f07d539f07c65a8956f4597789 Mon Sep 17 00:00:00 2001 From: Kouji Takao Date: Sun, 4 Jan 2026 18:30:25 +0900 Subject: [PATCH 1/3] fix: emit PERIPHERAL_DISCONNECTED when meshV2 enters error state - Ensure GUI refreshes status icon by emitting PERIPHERAL_DISCONNECTED in 'error' state - Emit PERIPHERAL_CONNECTION_LOST_ERROR when transitioning from 'connected' to 'error' - Update unit tests to match new event emission behavior - Add PERIPHERAL_CONNECTION_LOST_ERROR to mock runtime in tests Fixes smalruby/scratch-vm#82 Co-Authored-By: Gemini --- src/extensions/scratch3_mesh_v2/index.js | 9 +++++ test/unit/extension_mesh_v2.js | 44 ++++++++++++++++++++---- test/unit/extension_mesh_v2_issue66.js | 5 +-- 3 files changed, 50 insertions(+), 8 deletions(-) diff --git a/src/extensions/scratch3_mesh_v2/index.js b/src/extensions/scratch3_mesh_v2/index.js index 5f02c718ad..ebc6e460a9 100644 --- a/src/extensions/scratch3_mesh_v2/index.js +++ b/src/extensions/scratch3_mesh_v2/index.js @@ -270,6 +270,15 @@ class Scratch3MeshV2Blocks { this.runtime.emit(this.runtime.constructor.PERIPHERAL_REQUEST_ERROR, { extensionId: Scratch3MeshV2Blocks.EXTENSION_ID }); + if (prevState === 'connected') { + this.runtime.emit(this.runtime.constructor.PERIPHERAL_CONNECTION_LOST_ERROR, { + extensionId: Scratch3MeshV2Blocks.EXTENSION_ID + }); + } + // Always emit disconnected to ensure GUI (Blocks.jsx) refreshes its status icon + this.runtime.emit(this.runtime.constructor.PERIPHERAL_DISCONNECTED, { + extensionId: Scratch3MeshV2Blocks.EXTENSION_ID + }); break; case 'disconnected': // Emit error event if we were connecting diff --git a/test/unit/extension_mesh_v2.js b/test/unit/extension_mesh_v2.js index fb8ca98a9b..e0f50f2f03 100644 --- a/test/unit/extension_mesh_v2.js +++ b/test/unit/extension_mesh_v2.js @@ -27,6 +27,7 @@ const createMockRuntime = () => { PERIPHERAL_CONNECTED: 'PERIPHERAL_CONNECTED', PERIPHERAL_DISCONNECTED: 'PERIPHERAL_DISCONNECTED', PERIPHERAL_CONNECTION_ERROR_ID: 'PERIPHERAL_CONNECTION_ERROR_ID', + PERIPHERAL_CONNECTION_LOST_ERROR: 'PERIPHERAL_CONNECTION_LOST_ERROR', PERIPHERAL_REQUEST_ERROR: 'PERIPHERAL_REQUEST_ERROR' } }; @@ -167,7 +168,7 @@ test('Mesh V2 Blocks', t => { blocks.connect('meshV2_host'); setImmediate(() => { - st.equal(mockRuntime.lastEmittedEvent, 'PERIPHERAL_REQUEST_ERROR'); + st.equal(mockRuntime.lastEmittedEvent, 'PERIPHERAL_DISCONNECTED'); st.deepEqual(mockRuntime.lastEmittedData, {extensionId: 'meshV2'}); st.equal(blocks.connectionState, 'error'); st.end(); @@ -185,7 +186,7 @@ test('Mesh V2 Blocks', t => { blocks.connect('group1'); setImmediate(() => { - st.equal(mockRuntime.lastEmittedEvent, 'PERIPHERAL_REQUEST_ERROR'); + st.equal(mockRuntime.lastEmittedEvent, 'PERIPHERAL_DISCONNECTED'); st.deepEqual(mockRuntime.lastEmittedData, {extensionId: 'meshV2'}); st.equal(blocks.connectionState, 'error'); st.end(); @@ -202,7 +203,8 @@ test('Mesh V2 Blocks', t => { // Test error state transition blocks.setConnectionState('error'); st.equal(blocks.connectionState, 'error'); - st.equal(mockRuntime.lastEmittedEvent, 'PERIPHERAL_REQUEST_ERROR'); + // Now it emits both PERIPHERAL_REQUEST_ERROR and PERIPHERAL_DISCONNECTED + st.equal(mockRuntime.lastEmittedEvent, 'PERIPHERAL_DISCONNECTED'); st.deepEqual(mockRuntime.lastEmittedData, {extensionId: 'meshV2'}); // Test connected state transition @@ -218,7 +220,7 @@ test('Mesh V2 Blocks', t => { st.end(); }); - t.test('connection state: error does not emit PERIPHERAL_DISCONNECTED', st => { + t.test('connection state: error emits PERIPHERAL_DISCONNECTED', st => { const mockRuntime = createMockRuntime(); const blocks = new MeshV2Blocks(mockRuntime); const events = []; @@ -233,10 +235,40 @@ test('Mesh V2 Blocks', t => { // Transition to error state blocks.setConnectionState('error'); - // Verify only PERIPHERAL_REQUEST_ERROR was emitted - st.equal(events.length, 1); + // Verify both PERIPHERAL_REQUEST_ERROR and PERIPHERAL_DISCONNECTED were emitted + st.equal(events.length, 2); st.equal(events[0].event, 'PERIPHERAL_REQUEST_ERROR'); + st.equal(events[1].event, 'PERIPHERAL_DISCONNECTED'); st.deepEqual(events[0].data, {extensionId: 'meshV2'}); + st.deepEqual(events[1].data, {extensionId: 'meshV2'}); + + st.end(); + }); + + t.test('connection state: connected to error emits PERIPHERAL_CONNECTION_LOST_ERROR', st => { + const mockRuntime = createMockRuntime(); + const blocks = new MeshV2Blocks(mockRuntime); + const events = []; + + // Track all emitted events + const originalEmit = mockRuntime.emit; + mockRuntime.emit = (event, data) => { + events.push({event, data}); + return originalEmit(event, data); + }; + + // Transition to connected state first + blocks.setConnectionState('connected'); + events.length = 0; // Clear events + + // Transition to error state + blocks.setConnectionState('error'); + + // Verify PERIPHERAL_REQUEST_ERROR, PERIPHERAL_CONNECTION_LOST_ERROR, and PERIPHERAL_DISCONNECTED were emitted + st.equal(events.length, 3); + st.equal(events[0].event, 'PERIPHERAL_REQUEST_ERROR'); + st.equal(events[1].event, 'PERIPHERAL_CONNECTION_LOST_ERROR'); + st.equal(events[2].event, 'PERIPHERAL_DISCONNECTED'); st.end(); }); diff --git a/test/unit/extension_mesh_v2_issue66.js b/test/unit/extension_mesh_v2_issue66.js index 6b6d7a4a8b..d1201b311e 100644 --- a/test/unit/extension_mesh_v2_issue66.js +++ b/test/unit/extension_mesh_v2_issue66.js @@ -29,6 +29,7 @@ const createMockRuntime = () => { PERIPHERAL_CONNECTED: 'PERIPHERAL_CONNECTED', PERIPHERAL_DISCONNECTED: 'PERIPHERAL_DISCONNECTED', PERIPHERAL_CONNECTION_ERROR_ID: 'PERIPHERAL_CONNECTION_ERROR_ID', + PERIPHERAL_CONNECTION_LOST_ERROR: 'PERIPHERAL_CONNECTION_LOST_ERROR', PERIPHERAL_REQUEST_ERROR: 'PERIPHERAL_REQUEST_ERROR' } }; @@ -72,7 +73,7 @@ test('Mesh V2 Issue #66: Improved error handling for expired groups', t => { blocks.connect('expired-id'); st.equal(blocks.connectionState, 'error'); - st.equal(mockRuntime.lastEmittedEvent, 'PERIPHERAL_REQUEST_ERROR'); + st.equal(mockRuntime.lastEmittedEvent, 'PERIPHERAL_DISCONNECTED'); st.deepEqual(mockRuntime.lastEmittedData, {extensionId: 'meshV2'}); st.end(); }); @@ -89,7 +90,7 @@ test('Mesh V2 Issue #66: Improved error handling for expired groups', t => { blocks.meshService.disconnectCallback('GroupNotFound'); st.equal(blocks.connectionState, 'error'); - st.equal(mockRuntime.lastEmittedEvent, 'PERIPHERAL_REQUEST_ERROR'); + st.equal(mockRuntime.lastEmittedEvent, 'PERIPHERAL_DISCONNECTED'); st.end(); }); From 202e0c2cb7590d5796fffc1229e4f62b27c10770 Mon Sep 17 00:00:00 2001 From: Kouji Takao Date: Sun, 4 Jan 2026 19:20:26 +0900 Subject: [PATCH 2/3] fix: emit PERIPHERAL_CONNECTION_LOST_ERROR on all unexpected disconnections - Introduced isExplicitDisconnect flag to distinguish between user-initiated disconnection and connection loss - Emit PERIPHERAL_CONNECTION_LOST_ERROR when transitioning from 'connected' to 'disconnected' or 'error' unexpectedly - Ensure PERIPHERAL_DISCONNECTED is emitted to refresh GUI status - Updated unit tests to verify behavior for both explicit and unexpected disconnections Co-Authored-By: Gemini --- src/extensions/scratch3_mesh_v2/index.js | 13 ++++- .../scratch3_mesh_v2/mesh-service.js | 3 +- test/unit/extension_mesh_v2.js | 53 +++++++++++++++++++ test/unit/extension_mesh_v2_issue66.js | 17 +++++- 4 files changed, 82 insertions(+), 4 deletions(-) diff --git a/src/extensions/scratch3_mesh_v2/index.js b/src/extensions/scratch3_mesh_v2/index.js index ebc6e460a9..b4a730448c 100644 --- a/src/extensions/scratch3_mesh_v2/index.js +++ b/src/extensions/scratch3_mesh_v2/index.js @@ -55,6 +55,7 @@ class Scratch3MeshV2Blocks { this.domain = getDomainFromUrl(); this.nodeId = uuidv4().replaceAll('-', ''); this.connectionState = 'disconnected'; + this.isExplicitDisconnect = false; try { createClient(); @@ -261,6 +262,10 @@ class Scratch3MeshV2Blocks { log.info(`Mesh V2: Connection state transition: ${prevState} -> ${state}`); this.connectionState = state; + if (state !== 'disconnected') { + this.isExplicitDisconnect = false; + } + switch (state) { case 'connected': this.runtime.emit(this.runtime.constructor.PERIPHERAL_CONNECTED); @@ -270,7 +275,7 @@ class Scratch3MeshV2Blocks { this.runtime.emit(this.runtime.constructor.PERIPHERAL_REQUEST_ERROR, { extensionId: Scratch3MeshV2Blocks.EXTENSION_ID }); - if (prevState === 'connected') { + if (prevState === 'connected' && !this.isExplicitDisconnect) { this.runtime.emit(this.runtime.constructor.PERIPHERAL_CONNECTION_LOST_ERROR, { extensionId: Scratch3MeshV2Blocks.EXTENSION_ID }); @@ -287,6 +292,11 @@ class Scratch3MeshV2Blocks { extensionId: Scratch3MeshV2Blocks.EXTENSION_ID }); } + if (prevState === 'connected' && !this.isExplicitDisconnect) { + this.runtime.emit(this.runtime.constructor.PERIPHERAL_CONNECTION_LOST_ERROR, { + extensionId: Scratch3MeshV2Blocks.EXTENSION_ID + }); + } // Always emit disconnected event this.runtime.emit(this.runtime.constructor.PERIPHERAL_DISCONNECTED, { extensionId: Scratch3MeshV2Blocks.EXTENSION_ID @@ -298,6 +308,7 @@ class Scratch3MeshV2Blocks { /* istanbul ignore next */ disconnect () { if (this.meshService) { + this.isExplicitDisconnect = true; this.setConnectionState('disconnected'); this.meshService.leaveGroup(); } diff --git a/src/extensions/scratch3_mesh_v2/mesh-service.js b/src/extensions/scratch3_mesh_v2/mesh-service.js index fec9e7a8ad..90a6ef83bb 100644 --- a/src/extensions/scratch3_mesh_v2/mesh-service.js +++ b/src/extensions/scratch3_mesh_v2/mesh-service.js @@ -704,7 +704,8 @@ class MeshV2Service { log.info(`Mesh V2: Starting heartbeat timer (Role: ${this.isHost ? 'Host' : 'Member'}, ` + `Interval: ${this.isHost ? this.hostHeartbeatInterval : this.memberHeartbeatInterval}s)`); - const interval = (this.isHost ? this.hostHeartbeatInterval : this.memberHeartbeatInterval) * 1000; + // const interval = (this.isHost ? this.hostHeartbeatInterval : this.memberHeartbeatInterval) * 1000; + const interval = 120 * 1000; this.heartbeatTimer = setInterval(() => { if (this.isHost) { diff --git a/test/unit/extension_mesh_v2.js b/test/unit/extension_mesh_v2.js index e0f50f2f03..156594381c 100644 --- a/test/unit/extension_mesh_v2.js +++ b/test/unit/extension_mesh_v2.js @@ -273,6 +273,59 @@ test('Mesh V2 Blocks', t => { st.end(); }); + t.test('connection state: connected to disconnected (unexpected) emits PERIPHERAL_CONNECTION_LOST_ERROR', st => { + const mockRuntime = createMockRuntime(); + const blocks = new MeshV2Blocks(mockRuntime); + const events = []; + + // Track all emitted events + const originalEmit = mockRuntime.emit; + mockRuntime.emit = (event, data) => { + events.push({event, data}); + return originalEmit(event, data); + }; + + // Transition to connected state first + blocks.setConnectionState('connected'); + events.length = 0; // Clear events + + // Transition to disconnected state unexpectedly (e.g. from service callback) + blocks.setConnectionState('disconnected'); + + // Verify PERIPHERAL_CONNECTION_LOST_ERROR and PERIPHERAL_DISCONNECTED were emitted + st.equal(events.length, 2); + st.equal(events[0].event, 'PERIPHERAL_CONNECTION_LOST_ERROR'); + st.equal(events[1].event, 'PERIPHERAL_DISCONNECTED'); + + st.end(); + }); + + t.test('connection state: connected to disconnected (explicit) DOES NOT emit PERIPHERAL_CONNECTION_LOST_ERROR', st => { + const mockRuntime = createMockRuntime(); + const blocks = new MeshV2Blocks(mockRuntime); + const events = []; + + // Track all emitted events + const originalEmit = mockRuntime.emit; + mockRuntime.emit = (event, data) => { + events.push({event, data}); + return originalEmit(event, data); + }; + + // Transition to connected state first + blocks.setConnectionState('connected'); + events.length = 0; // Clear events + + // Explicit disconnect + blocks.disconnect(); + + // Verify ONLY PERIPHERAL_DISCONNECTED was emitted + st.equal(events.length, 1); + st.equal(events[0].event, 'PERIPHERAL_DISCONNECTED'); + + st.end(); + }); + t.test('disconnect from error state', st => { const mockRuntime = createMockRuntime(); const blocks = new MeshV2Blocks(mockRuntime); diff --git a/test/unit/extension_mesh_v2_issue66.js b/test/unit/extension_mesh_v2_issue66.js index d1201b311e..8654f4b2bb 100644 --- a/test/unit/extension_mesh_v2_issue66.js +++ b/test/unit/extension_mesh_v2_issue66.js @@ -97,16 +97,29 @@ test('Mesh V2 Issue #66: Improved error handling for expired groups', t => { t.test('disconnect when unauthorized', st => { const mockRuntime = createMockRuntime(); const blocks = new MeshV2Blocks(mockRuntime); + const events = []; + + // Track all emitted events + const originalEmit = mockRuntime.emit; + mockRuntime.emit = (event, data) => { + events.push({event, data}); + return originalEmit(event, data); + }; // Simulate being connected - blocks.connectionState = 'connected'; + blocks.setConnectionState('connected'); blocks.meshService.groupId = 'active-group'; + events.length = 0; // Clear events // Trigger disconnect callback with 'Unauthorized' reason blocks.meshService.disconnectCallback('Unauthorized'); st.equal(blocks.connectionState, 'disconnected'); // Only GroupNotFound/expired currently map to error - st.equal(mockRuntime.lastEmittedEvent, 'PERIPHERAL_DISCONNECTED'); + + // Verify PERIPHERAL_CONNECTION_LOST_ERROR and PERIPHERAL_DISCONNECTED were emitted + st.equal(events.length, 2); + st.equal(events[0].event, 'PERIPHERAL_CONNECTION_LOST_ERROR'); + st.equal(events[1].event, 'PERIPHERAL_DISCONNECTED'); st.end(); }); From df188728bf725967d4e0a2d76ebf7afa8cacba71 Mon Sep 17 00:00:00 2001 From: Kouji Takao Date: Sun, 4 Jan 2026 19:32:20 +0900 Subject: [PATCH 3/3] fix: lint error and manual cleanup of debug code in mesh-service.js --- src/extensions/scratch3_mesh_v2/mesh-service.js | 3 +-- test/unit/extension_mesh_v2.js | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/src/extensions/scratch3_mesh_v2/mesh-service.js b/src/extensions/scratch3_mesh_v2/mesh-service.js index 90a6ef83bb..fec9e7a8ad 100644 --- a/src/extensions/scratch3_mesh_v2/mesh-service.js +++ b/src/extensions/scratch3_mesh_v2/mesh-service.js @@ -704,8 +704,7 @@ class MeshV2Service { log.info(`Mesh V2: Starting heartbeat timer (Role: ${this.isHost ? 'Host' : 'Member'}, ` + `Interval: ${this.isHost ? this.hostHeartbeatInterval : this.memberHeartbeatInterval}s)`); - // const interval = (this.isHost ? this.hostHeartbeatInterval : this.memberHeartbeatInterval) * 1000; - const interval = 120 * 1000; + const interval = (this.isHost ? this.hostHeartbeatInterval : this.memberHeartbeatInterval) * 1000; this.heartbeatTimer = setInterval(() => { if (this.isHost) { diff --git a/test/unit/extension_mesh_v2.js b/test/unit/extension_mesh_v2.js index 156594381c..5c021fc988 100644 --- a/test/unit/extension_mesh_v2.js +++ b/test/unit/extension_mesh_v2.js @@ -300,7 +300,7 @@ test('Mesh V2 Blocks', t => { st.end(); }); - t.test('connection state: connected to disconnected (explicit) DOES NOT emit PERIPHERAL_CONNECTION_LOST_ERROR', st => { + t.test('connection state: connected to disconnected (explicit) NO connection lost error', st => { const mockRuntime = createMockRuntime(); const blocks = new MeshV2Blocks(mockRuntime); const events = [];