Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 17 additions & 2 deletions src/extensions/scratch3_mesh_v2/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -59,8 +59,12 @@ class Scratch3MeshV2Blocks {
try {
createClient();
this.meshService = new MeshV2Service(this, this.nodeId, this.domain);
this.meshService.setDisconnectCallback(() => {
this.setConnectionState('disconnected');
this.meshService.setDisconnectCallback(reason => {
if (reason === 'GroupNotFound' || reason === 'expired') {
this.setConnectionState('error');
} else {
this.setConnectionState('disconnected');
}
});
log.info(`Mesh V2: Initialized with domain ${this.domain || 'null (auto)'} and nodeId ${this.nodeId}`);

Expand Down Expand Up @@ -224,6 +228,17 @@ class Scratch3MeshV2Blocks {
});
} else {
const group = this.discoveredGroups && this.discoveredGroups.find(g => g.id === id);

// Validate expiration before joining
if (group && group.expiresAt) {
const expiresAtMs = new Date(group.expiresAt).getTime();
if (expiresAtMs <= Date.now()) {
log.error('Mesh V2: Cannot join expired group');
this.setConnectionState('error');
return;
}
}

const domain = group ? group.domain : null;
const groupName = group ? group.name : id;
this.meshService.joinGroup(id, domain, groupName).then(() => {
Expand Down
35 changes: 20 additions & 15 deletions src/extensions/scratch3_mesh_v2/mesh-service.js
Original file line number Diff line number Diff line change
Expand Up @@ -93,17 +93,17 @@ class MeshV2Service {
* Check if the error indicates the group/node is no longer valid.
* Uses errorType from GraphQL response for robust error detection.
* @param {Error} error - The error to check.
* @returns {boolean} true if should disconnect.
* @returns {string|null} The error reason if should disconnect, null otherwise.
*/
shouldDisconnectOnError (error) {
if (!error) return false;
if (!error) return null;

// Primary check: GraphQL errorType (most reliable)
if (error.graphQLErrors && error.graphQLErrors.length > 0) {
const errorType = error.graphQLErrors[0].errorType;
if (DISCONNECT_ERROR_TYPES.has(errorType)) {
log.info(`Mesh V2: Disconnecting due to errorType: ${errorType}`);
return true;
return errorType;
}
}

Expand All @@ -115,21 +115,21 @@ class MeshV2Service {
message.includes('expired') ||
message.includes('unauthorized')) {
log.warn('Mesh V2: Disconnecting based on error message (fallback). Consider checking errorType.');
return true;
return 'expired';
}
}

return false;
return null;
}

setDisconnectCallback (callback) {
this.disconnectCallback = callback;
}

cleanupAndDisconnect () {
cleanupAndDisconnect (reason = 'unknown') {
this.cleanup();
if (this.disconnectCallback) {
this.disconnectCallback();
this.disconnectCallback(reason);
}
}

Expand Down Expand Up @@ -231,6 +231,7 @@ class MeshV2Service {
this.groupId = groupId;
this.groupName = groupName || groupId;
this.domain = node.domain; // Update domain from server
this.expiresAt = node.expiresAt;
this.isHost = false;
if (node.heartbeatIntervalSeconds) {
this.memberHeartbeatInterval = node.heartbeatIntervalSeconds;
Expand Down Expand Up @@ -559,8 +560,9 @@ class MeshV2Service {
});
} catch (error) {
log.error(`Mesh V2: Failed to fire batch events: ${error}`);
if (this.shouldDisconnectOnError(error)) {
this.cleanupAndDisconnect();
const reason = this.shouldDisconnectOnError(error);
if (reason) {
this.cleanupAndDisconnect(reason);
}
}
}
Expand Down Expand Up @@ -608,8 +610,9 @@ class MeshV2Service {
return result.data.renewHeartbeat;
} catch (error) {
log.error(`Mesh V2: Heartbeat renewal failed: ${error}`);
if (this.shouldDisconnectOnError(error)) {
this.cleanupAndDisconnect();
const reason = this.shouldDisconnectOnError(error);
if (reason) {
this.cleanupAndDisconnect(reason);
}
}
}
Expand Down Expand Up @@ -641,8 +644,9 @@ class MeshV2Service {
return result.data.sendMemberHeartbeat;
} catch (error) {
log.error(`Mesh V2: Member heartbeat failed: ${error}`);
if (this.shouldDisconnectOnError(error)) {
this.cleanupAndDisconnect();
const reason = this.shouldDisconnectOnError(error);
if (reason) {
this.cleanupAndDisconnect(reason);
}
}
}
Expand Down Expand Up @@ -699,8 +703,9 @@ class MeshV2Service {
await this.dataRateLimiter.send(dataArray, this._reportDataBound);
} catch (error) {
log.error(`Mesh V2: Failed to send data: ${error}`);
if (this.shouldDisconnectOnError(error)) {
this.cleanupAndDisconnect();
const reason = this.shouldDisconnectOnError(error);
if (reason) {
this.cleanupAndDisconnect(reason);
}
}
}
Expand Down
14 changes: 7 additions & 7 deletions test/unit/extension_mesh_v2.js
Original file line number Diff line number Diff line change
Expand Up @@ -359,7 +359,7 @@ test('Mesh V2 Blocks', t => {
errorType: 'GroupNotFound'
}]
};
st.equal(blocks.meshService.shouldDisconnectOnError(groupNotFoundError), true);
st.equal(blocks.meshService.shouldDisconnectOnError(groupNotFoundError), 'GroupNotFound');

// GraphQL errorType: Unauthorized
const unauthorizedError = {
Expand All @@ -368,7 +368,7 @@ test('Mesh V2 Blocks', t => {
errorType: 'Unauthorized'
}]
};
st.equal(blocks.meshService.shouldDisconnectOnError(unauthorizedError), true);
st.equal(blocks.meshService.shouldDisconnectOnError(unauthorizedError), 'Unauthorized');

// GraphQL errorType: NodeNotFound
const nodeNotFoundError = {
Expand All @@ -377,7 +377,7 @@ test('Mesh V2 Blocks', t => {
errorType: 'NodeNotFound'
}]
};
st.equal(blocks.meshService.shouldDisconnectOnError(nodeNotFoundError), true);
st.equal(blocks.meshService.shouldDisconnectOnError(nodeNotFoundError), 'NodeNotFound');

// GraphQL errorType: ValidationError (should NOT disconnect)
const validationError = {
Expand All @@ -386,25 +386,25 @@ test('Mesh V2 Blocks', t => {
errorType: 'ValidationError'
}]
};
st.equal(blocks.meshService.shouldDisconnectOnError(validationError), false);
st.equal(blocks.meshService.shouldDisconnectOnError(validationError), null);

// Fallback: message string matching
const messageOnlyError = {
message: 'GraphQL error: Group not found'
};
st.equal(blocks.meshService.shouldDisconnectOnError(messageOnlyError), true);
st.equal(blocks.meshService.shouldDisconnectOnError(messageOnlyError), 'expired');

const expiredMessageError = {
message: 'Group expired'
};
st.equal(blocks.meshService.shouldDisconnectOnError(expiredMessageError), true);
st.equal(blocks.meshService.shouldDisconnectOnError(expiredMessageError), 'expired');

// Network error (should NOT disconnect)
const networkError = {
message: 'Network request failed',
networkError: new Error('Fetch failed')
};
st.equal(blocks.meshService.shouldDisconnectOnError(networkError), false);
st.equal(blocks.meshService.shouldDisconnectOnError(networkError), null);

st.end();
});
Expand Down
137 changes: 137 additions & 0 deletions test/unit/extension_mesh_v2_issue66.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
const test = require('tap').test;
const URLSearchParams = require('url').URLSearchParams;
const MeshV2Blocks = require('../../src/extensions/scratch3_mesh_v2/index.js');
const Variable = require('../../src/engine/variable');

const createMockRuntime = () => {
const runtime = {
registerPeripheralExtension: () => {},
on: () => {},
emit: (event, data) => {
if (!runtime.emittedEvents) runtime.emittedEvents = [];
runtime.emittedEvents.push({event, data});
runtime.lastEmittedEvent = event;
runtime.lastEmittedData = data;
},
getOpcodeFunction: () => () => {},
createNewGlobalVariable: name => ({type: Variable.SCALAR_TYPE, name: name || 'var1', value: 0}),
_primitives: {},
extensionManager: {
isExtensionLoaded: () => false
},
constructor: {
PERIPHERAL_LIST_UPDATE: 'PERIPHERAL_LIST_UPDATE',
PERIPHERAL_CONNECTED: 'PERIPHERAL_CONNECTED',
PERIPHERAL_DISCONNECTED: 'PERIPHERAL_DISCONNECTED',
PERIPHERAL_CONNECTION_ERROR_ID: 'PERIPHERAL_CONNECTION_ERROR_ID',
PERIPHERAL_REQUEST_ERROR: 'PERIPHERAL_REQUEST_ERROR'
}
};
const stage = {
variables: {},
getCustomVars: () => [],
lookupVariableById: id => stage.variables[id] || {id: id, name: 'var1', value: 0, type: Variable.SCALAR_TYPE},
lookupVariableByNameAndType: () => null,
lookupOrCreateVariable: () => ({}),
createVariable: () => {},
setVariableValue: () => {},
renameVariable: () => {}
};
runtime.getTargetForStage = () => stage;
return runtime;
};

test('Mesh V2 Issue #66: Improved error handling for expired groups', t => {
// Set up global window for utils
global.window = {
location: {
search: '?mesh=test-domain'
}
};
global.URLSearchParams = URLSearchParams;

t.test('connect to expired group (client-side validation)', st => {
const mockRuntime = createMockRuntime();
const blocks = new MeshV2Blocks(mockRuntime);
const now = Date.now();

// Mock a group that just expired
const expiredGroup = {
id: 'expired-id',
name: 'Expired Group',
domain: 'test-domain',
expiresAt: new Date(now - 1000).toISOString()
};
blocks.discoveredGroups = [expiredGroup];

blocks.connect('expired-id');

st.equal(blocks.connectionState, 'error');
st.equal(mockRuntime.lastEmittedEvent, 'PERIPHERAL_REQUEST_ERROR');
st.deepEqual(mockRuntime.lastEmittedData, {extensionId: 'meshV2'});
st.end();
});

t.test('disconnect when group expires during operation', st => {
const mockRuntime = createMockRuntime();
const blocks = new MeshV2Blocks(mockRuntime);

// Simulate being connected
blocks.connectionState = 'connected';
blocks.meshService.groupId = 'active-group';

// Trigger disconnect callback with 'GroupNotFound' reason (expired)
blocks.meshService.disconnectCallback('GroupNotFound');

st.equal(blocks.connectionState, 'error');
st.equal(mockRuntime.lastEmittedEvent, 'PERIPHERAL_REQUEST_ERROR');
st.end();
});

t.test('disconnect when unauthorized', st => {
const mockRuntime = createMockRuntime();
const blocks = new MeshV2Blocks(mockRuntime);

// Simulate being connected
blocks.connectionState = 'connected';
blocks.meshService.groupId = 'active-group';

// 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');
st.end();
});

t.test('meshService.shouldDisconnectOnError returns reason', st => {
const mockRuntime = createMockRuntime();
const blocks = new MeshV2Blocks(mockRuntime);

const error = {
graphQLErrors: [{
errorType: 'GroupNotFound'
}]
};

const reason = blocks.meshService.shouldDisconnectOnError(error);
st.equal(reason, 'GroupNotFound');
st.end();
});

t.test('meshService.cleanupAndDisconnect passes reason to callback', st => {
const mockRuntime = createMockRuntime();
const blocks = new MeshV2Blocks(mockRuntime);

let capturedReason = null;
blocks.meshService.setDisconnectCallback(reason => {
capturedReason = reason;
});

blocks.meshService.cleanupAndDisconnect('test-reason');
st.equal(capturedReason, 'test-reason');
st.end();
});

t.end();
});
Loading