Skip to content

Commit 6682f53

Browse files
committed
add circular dependency validation for collaboration blocks
1 parent d58344f commit 6682f53

File tree

2 files changed

+156
-1
lines changed

2 files changed

+156
-1
lines changed

src/addons/addons/collaboration/helpers/helper.js

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -383,4 +383,44 @@ export function CommentMove(blocklyEvent, remoteTargetName) {
383383
} catch (e) {
384384
console.error(`Collab RX: Error moving comment with ID "${blocklyEvent.commentId}" on target "${remoteTargetName}":`, e, blocklyEvent);
385385
}
386-
}
386+
}
387+
388+
export function hasCircularDependency(blocksObject) {
389+
const blockIds = Object.keys(blocksObject);
390+
for (const startId of blockIds) {
391+
const visitedInPath = new Set(); // Tracks nodes for the CURRENT traversal path
392+
393+
function traverse(blockId) {
394+
if (!blockId) return false; // End of a chain
395+
if (visitedInPath.has(blockId)) {
396+
console.error(`Collab Validation: Circular dependency detected! Path includes block ${blockId} twice.`);
397+
return true; // Cycle detected!
398+
}
399+
if (!blocksObject[blockId]) {
400+
// This block is referenced but doesn't exist in the object, which is a data integrity issue but not a cycle.
401+
return false;
402+
}
403+
404+
visitedInPath.add(blockId);
405+
406+
const block = blocksObject[blockId];
407+
// Recurse through 'next' and all 'inputs'
408+
if (traverse(block.next)) return true;
409+
if (block.inputs) {
410+
for (const inputName in block.inputs) {
411+
// An input can be a shadow block (ID in `block.inputs[...].shadow`) or a real block (ID in `block.inputs[...].block`)
412+
if (traverse(block.inputs[inputName].block)) return true;
413+
}
414+
}
415+
416+
visitedInPath.delete(blockId); // Backtrack: remove from current path
417+
return false;
418+
}
419+
420+
if (traverse(startId)) {
421+
// Found a cycle starting from this block, no need to check others.
422+
return true;
423+
}
424+
}
425+
return false; // No cycles found
426+
}

src/addons/addons/collaboration/userscript.js

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -291,7 +291,32 @@ function attachYjsProvider() {
291291
if (constants.debugging) console.log('Collab: Initial sync data already exists, applying it now.');
292292
const syncData = constants.mutableRefs.yProjectDataSync.get('sync');
293293
if (syncData && syncData.data) {
294+
let isCorrupt = false;
295+
for (const targetData of syncData.data) {
296+
const blocksObject = JSON.parse(targetData.blockData);
297+
if (helper.hasCircularDependency(blocksObject)) {
298+
isCorrupt = true;
299+
break;
300+
}
301+
}
294302

303+
if (isCorrupt) {
304+
console.error("Collab FATAL: Received corrupt master copy with circular dependency. Aborting project load.");
305+
collabUI.hideSyncingPopup(); // Hide the "syncing" message
306+
307+
const popup = document.createElement('div');
308+
popup.className = 'collab-popup';
309+
popup.innerHTML = `
310+
<div class="collab-popup-content">
311+
<h2>Collaboration Error</h2>
312+
<p>The project data from the session is corrupt and cannot be loaded. This session is in an unrecoverable state.</p>
313+
<p>Please report this to @CodeTorch.</p>
314+
</div>
315+
`;
316+
document.body.appendChild(popup);
317+
318+
return; // Abort applying the sync data.
319+
}
295320
// IMPORTANT: Clear all existing blocks from all targets in the VM first.
296321
// This prevents duplication and ensures a clean slate before applying remote blocks.
297322
constants.mutableRefs.vm.runtime.targets.forEach(t => {
@@ -597,6 +622,96 @@ function attachYjsProvider() {
597622
}
598623
// --- 'savedProject' Trigger (for project save operations) ---
599624
else if (detail.triggerId === 'savedProject') {
625+
let isCorrupt = false;
626+
// Loop through every target (Sprite, Stage) in the project.
627+
for (const target of constants.mutableRefs.vm.runtime.targets) {
628+
const blocksToValidate = target.blocks._blocks;
629+
if (helper.hasCircularDependency(blocksToValidate)) {
630+
console.error(`Collab FATAL: Circular dependency detected in target "${target.getName()}". Aborting sync.`);
631+
isCorrupt = true;
632+
break; // Stop checking as soon as one corrupt target is found.
633+
}
634+
}
635+
636+
if (isCorrupt) {
637+
// Create the main popup container.
638+
const popup = document.createElement('div');
639+
popup.className = 'collab-popup';
640+
popup.innerHTML = `
641+
<div class="collab-popup-content">
642+
<h2>Project Sync Error</h2>
643+
<p>An invalid block connection (a loop) was detected in your project. This can sometimes happen after complex block movements.</p>
644+
<p>To fix this, the page needs to be reloaded. Your work should be saved up to this point.</p>
645+
<p>Please send the debug log to @CodeTorch.</p>
646+
<button id="collab-reload-button">Reload Project</button>
647+
</div>
648+
`;
649+
650+
// Get a reference to the content area of the popup to append buttons.
651+
const popupContent = popup.querySelector('.collab-popup-content');
652+
653+
// --- Button 1: Download Project (Unchanged) ---
654+
const downloadProjectButton = document.createElement('button');
655+
downloadProjectButton.innerText = 'Download Project';
656+
downloadProjectButton.addEventListener('click', () => {
657+
document.querySelectorAll('[class*="menu-bar_menu-bar-item_"]')[1].click();
658+
setTimeout(() => {
659+
document.querySelectorAll('li[class*="menu_menu-item_"]')[3].click();
660+
}, 500);
661+
});
662+
popupContent.appendChild(downloadProjectButton);
663+
664+
// --- Button 2 (NEW): Download Debug Log ---
665+
const downloadLogButton = document.createElement('button');
666+
downloadLogButton.innerText = 'Download Debug Log';
667+
downloadLogButton.addEventListener('click', () => {
668+
try {
669+
// Get the current state of the Yjs event arrays. .toArray() converts them to standard JS arrays.
670+
const yEventsData = constants.mutableRefs.yEvents.toArray();
671+
const yProjectEventsData = constants.mutableRefs.yProjectEvents.toArray();
672+
673+
// Format the data into a readable string with headers.
674+
const logContent = `Collaboration Addon Debug Log\n` +
675+
`Timestamp: ${new Date().toISOString()}\n\n` +
676+
`--- YEvents (Block Actions) ---\n\n` +
677+
`${JSON.stringify(yEventsData, null, 2)}\n\n` +
678+
`--- YProjectEvents (Asset & Project Actions) ---\n\n` +
679+
`${JSON.stringify(yProjectEventsData, null, 2)}`;
680+
681+
// Create a Blob from the string content.
682+
const blob = new Blob([logContent], { type: 'text/plain;charset=utf-8' });
683+
684+
// Create a temporary link element to trigger the download.
685+
const url = URL.createObjectURL(blob);
686+
const a = document.createElement('a');
687+
a.style.display = 'none';
688+
a.href = url;
689+
a.download = 'collaboration_debug_log.txt';
690+
691+
document.body.appendChild(a);
692+
a.click();
693+
694+
// Clean up by revoking the URL and removing the link.
695+
window.URL.revokeObjectURL(url);
696+
document.body.removeChild(a);
697+
} catch (e) {
698+
console.error("Collab: Failed to generate or download debug log.", e);
699+
alert("Sorry, the debug log could not be created.");
700+
}
701+
});
702+
popupContent.appendChild(downloadLogButton);
703+
704+
// Display the popup.
705+
document.body.appendChild(popup);
706+
707+
// Add the listener for the "Reload" button (which was created via innerHTML).
708+
document.getElementById('collab-reload-button').addEventListener('click', () => {
709+
window.location.reload();
710+
});
711+
712+
return; // Abort the save/sync process.
713+
}
714+
600715
// Get the current project data for a full sync snapshot.
601716
const projectDataSync = {
602717
type: 'projectDataSync',

0 commit comments

Comments
 (0)