-
Notifications
You must be signed in to change notification settings - Fork 0
Add VM snapshot functionality for time-travel debugging #29
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: develop
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,379 @@ | ||
| /** | ||
| * @fileoverview | ||
| * A snapshot serializer and deserializer for VM runtime state. | ||
| * Captures complete VM state for time-travel debugging. | ||
| */ | ||
|
|
||
| const sb3 = require('./sb3'); | ||
| const Thread = require('../engine/thread'); | ||
| const Variable = require('../engine/variable'); | ||
| const log = require('../util/log'); | ||
|
|
||
| /** | ||
| * Serialize the current runtime state into a snapshot. | ||
| * @param {!Runtime} runtime VM runtime instance to be serialized. | ||
| * @return {object} Snapshot object containing project and runtime state. | ||
| */ | ||
| const serialize = function (runtime) { | ||
| // Use existing SB3 serialization for project structure | ||
| const projectData = sb3.serialize(runtime); | ||
|
|
||
| // Initialize runtime state object | ||
| const runtimeState = { | ||
| version: '1.0.0', | ||
| timestamp: Date.now(), | ||
| targets: [], | ||
| monitors: [], | ||
| threads: [], | ||
| timers: {}, | ||
| ioDevices: {} | ||
| }; | ||
|
|
||
| // Capture target runtime state (positions, variables, etc.) | ||
| runtimeState.targets = runtime.targets.map(target => ({ | ||
| id: target.id, | ||
| x: target.x, | ||
| y: target.y, | ||
| direction: target.direction, | ||
| visible: target.visible, | ||
| size: target.size, | ||
| currentCostume: target.currentCostume, | ||
| effects: Object.assign({}, target.effects), | ||
| rotationStyle: target.rotationStyle, | ||
| draggable: target.draggable, | ||
| // Capture current variable values | ||
| variableValues: {}, | ||
| listValues: {} | ||
| })); | ||
|
|
||
| // Capture current variable and list values for each target | ||
| runtime.targets.forEach((target, index) => { | ||
| const targetState = runtimeState.targets[index]; | ||
| for (const varId in target.variables) { | ||
| const variable = target.variables[varId]; | ||
| if (variable.type === Variable.SCALAR_TYPE) { | ||
| targetState.variableValues[varId] = variable.value; | ||
| } else if (variable.type === Variable.LIST_TYPE) { | ||
| targetState.listValues[varId] = Array.isArray(variable.value) ? | ||
| [...variable.value] : variable.value; | ||
| } | ||
| } | ||
| }); | ||
|
|
||
| // Capture monitor state | ||
| if (runtime._monitorState) { | ||
| runtimeState.monitors = runtime._monitorState.valueSeq().toArray().map(monitor => ({ | ||
| id: monitor.get('id'), | ||
| value: monitor.get('value'), | ||
| visible: monitor.get('visible'), | ||
| mode: monitor.get('mode'), | ||
| x: monitor.get('x'), | ||
| y: monitor.get('y') | ||
| })); | ||
| } | ||
|
|
||
| // Capture thread execution state | ||
| runtimeState.threads = runtime.threads.map(thread => ({ | ||
| topBlock: thread.topBlock, | ||
| stack: [...thread.stack], | ||
| stackFrames: thread.stackFrames.map(frame => ({ | ||
| warpMode: frame.warpMode, | ||
| isLoop: frame.isLoop, | ||
| reporting: frame.reporting, | ||
| reported: frame.reported, | ||
| waitingReporter: frame.waitingReporter, | ||
| params: frame.params ? Object.assign({}, frame.params) : null, | ||
| executionContext: frame.executionContext ? Object.assign({}, frame.executionContext) : null | ||
| })), | ||
| status: thread.status, | ||
| targetId: thread.target ? thread.target.id : null, | ||
| stackClick: thread.stackClick, | ||
| updateMonitor: thread.updateMonitor, | ||
| warpTimer: thread.warpTimer ? { | ||
| startTime: thread.warpTimer.startTime, | ||
| timeElapsed: thread.warpTimer.timeElapsed() | ||
| } : null | ||
| })); | ||
|
|
||
| // Capture runtime timing state | ||
| runtimeState.timers = { | ||
| currentMSecs: runtime.currentMSecs, | ||
| currentStepTime: runtime.currentStepTime, | ||
| _lastStepTime: runtime._lastStepTime | ||
| }; | ||
|
|
||
| // Capture clock IO device state | ||
| if (runtime.ioDevices && runtime.ioDevices.clock) { | ||
| runtimeState.timers.projectTimer = runtime.ioDevices.clock._projectTimer; | ||
| runtimeState.timers.paused = runtime.ioDevices.clock._paused; | ||
| runtimeState.timers.pausedTime = runtime.ioDevices.clock._pausedTime; | ||
| } | ||
|
|
||
| // Capture IO device state | ||
| runtimeState.ioDevices = {}; | ||
|
|
||
| // Capture keyboard state | ||
| if (runtime.ioDevices && runtime.ioDevices.keyboard) { | ||
| runtimeState.ioDevices.keyboard = { | ||
| keysPressed: [...runtime.ioDevices.keyboard._keysPressed], | ||
| lastKeyPressed: runtime.ioDevices.keyboard.lastKeyPressed | ||
| }; | ||
| } | ||
|
|
||
| // Capture mouse state | ||
| if (runtime.ioDevices && runtime.ioDevices.mouse) { | ||
| runtimeState.ioDevices.mouse = { | ||
| clientX: runtime.ioDevices.mouse._clientX, | ||
| clientY: runtime.ioDevices.mouse._clientY, | ||
| scratchX: runtime.ioDevices.mouse._scratchX, | ||
| scratchY: runtime.ioDevices.mouse._scratchY, | ||
| isDown: runtime.ioDevices.mouse._isDown, | ||
| buttons: Array.from(runtime.ioDevices.mouse._buttons || []) | ||
| }; | ||
| } | ||
|
|
||
| // Capture video state | ||
| if (runtime.ioDevices && runtime.ioDevices.video) { | ||
| runtimeState.ioDevices.video = { | ||
| skinId: runtime.ioDevices.video._skinId, | ||
| drawable: runtime.ioDevices.video._drawable, | ||
| ghost: runtime.ioDevices.video._ghost | ||
| }; | ||
| } | ||
|
|
||
| return { | ||
| project: projectData, | ||
| runtimeState: runtimeState | ||
| }; | ||
| }; | ||
|
|
||
| /** | ||
| * Deserialize a snapshot and restore runtime state. | ||
| * @param {!Runtime} runtime VM runtime instance to restore state into. | ||
| * @param {!object} snapshot Snapshot object to deserialize. | ||
| * @return {Promise} Promise that resolves when deserialization is complete. | ||
| */ | ||
| const deserialize = function (runtime, snapshot) { | ||
| // Validate snapshot format | ||
| if (!snapshot || !snapshot.project || !snapshot.runtimeState) { | ||
| throw new Error('Invalid snapshot format: missing project or runtimeState'); | ||
| } | ||
|
|
||
| // Validate snapshot version | ||
| if (snapshot.runtimeState.version !== '1.0.0') { | ||
| log.warn(`Snapshot version ${snapshot.runtimeState.version} may not be fully compatible`); | ||
| } | ||
|
|
||
| // First restore the project structure using existing SB3 deserialization | ||
| return sb3.deserialize(snapshot.project, runtime) | ||
| .then(({targets, extensions}) => { | ||
| // Restore target runtime state | ||
| if (snapshot.runtimeState.targets) { | ||
| restoreTargetRuntimeState(runtime, snapshot.runtimeState.targets); | ||
| } | ||
|
|
||
| // Restore monitor state | ||
| if (snapshot.runtimeState.monitors) { | ||
| restoreMonitorState(runtime, snapshot.runtimeState.monitors); | ||
| } | ||
|
|
||
| // Restore thread execution state | ||
| if (snapshot.runtimeState.threads) { | ||
| restoreThreadState(runtime, snapshot.runtimeState.threads); | ||
| } | ||
|
|
||
| // Restore timer state | ||
| if (snapshot.runtimeState.timers) { | ||
| restoreTimerState(runtime, snapshot.runtimeState.timers); | ||
| } | ||
|
|
||
| // Restore IO device state | ||
| if (snapshot.runtimeState.ioDevices) { | ||
| restoreIODeviceState(runtime, snapshot.runtimeState.ioDevices); | ||
| } | ||
|
|
||
| return {targets, extensions}; | ||
| }); | ||
| }; | ||
|
|
||
| /** | ||
| * Restore runtime state for all targets (sprite positions, variable values, etc.) | ||
| * @param {!Runtime} runtime VM runtime instance | ||
| * @param {!Array} targetStates Array of target state objects from snapshot | ||
| */ | ||
| const restoreTargetRuntimeState = function (runtime, targetStates) { | ||
| targetStates.forEach(targetState => { | ||
| const target = runtime.getTargetById(targetState.id); | ||
| if (!target) { | ||
| log.warn(`Target with ID ${targetState.id} not found during snapshot restore`); | ||
| return; | ||
| } | ||
|
|
||
| // Restore sprite properties | ||
| if (typeof targetState.x !== 'undefined') target.setXY(targetState.x, targetState.y); | ||
| if (typeof targetState.direction !== 'undefined') target.setDirection(targetState.direction); | ||
| if (typeof targetState.visible !== 'undefined') target.setVisible(targetState.visible); | ||
| if (typeof targetState.size !== 'undefined') target.setSize(targetState.size); | ||
| if (typeof targetState.currentCostume !== 'undefined') target.setCostume(targetState.currentCostume); | ||
| if (typeof targetState.rotationStyle !== 'undefined') target.setRotationStyle(targetState.rotationStyle); | ||
| if (typeof targetState.draggable !== 'undefined') target.draggable = targetState.draggable; | ||
|
|
||
| // Restore effects | ||
| if (targetState.effects) { | ||
| for (const effectName in targetState.effects) { | ||
| target.setEffect(effectName, targetState.effects[effectName]); | ||
| } | ||
| } | ||
|
|
||
| // Restore variable values | ||
| if (targetState.variableValues) { | ||
| for (const varId in targetState.variableValues) { | ||
| if (target.variables[varId]) { | ||
| target.variables[varId].value = targetState.variableValues[varId]; | ||
| } | ||
| } | ||
| } | ||
|
|
||
| // Restore list values | ||
| if (targetState.listValues) { | ||
| for (const listId in targetState.listValues) { | ||
| if (target.variables[listId]) { | ||
| target.variables[listId].value = Array.isArray(targetState.listValues[listId]) ? | ||
| [...targetState.listValues[listId]] : targetState.listValues[listId]; | ||
| } | ||
| } | ||
| } | ||
| }); | ||
| }; | ||
|
|
||
| /** | ||
| * Restore monitor state (visibility, values, positions) | ||
| * @param {!Runtime} runtime VM runtime instance | ||
| * @param {!Array} monitorStates Array of monitor state objects from snapshot | ||
| */ | ||
| const restoreMonitorState = function (runtime, monitorStates) { | ||
| monitorStates.forEach(monitorState => { | ||
| // Update monitor using runtime's monitor update system | ||
| const monitorRecord = new Map([ | ||
| ['id', monitorState.id], | ||
| ['value', monitorState.value], | ||
| ['visible', monitorState.visible], | ||
| ['mode', monitorState.mode], | ||
| ['x', monitorState.x], | ||
| ['y', monitorState.y] | ||
| ]); | ||
|
|
||
| runtime.requestUpdateMonitor(monitorRecord); | ||
| }); | ||
| }; | ||
|
Comment on lines
+254
to
+268
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: # Search for requestUpdateMonitor definition and usage
rg -n 'requestUpdateMonitor' --type=js -B 3 -A 10Repository: OmniBlocks/scratch-vm Length of output: 10276 🏁 Script executed: # Also search for the serialization context - look for monitor-related code
rg -n 'monitorState|restoreMonitorState' --type=js -B 2 -A 5Repository: OmniBlocks/scratch-vm Length of output: 49475 🏁 Script executed: # Check if there's any Immutable.js usage pattern for Map objects
rg -n 'Immutable\.Map|new Map' src/serialization/snapshot.js -B 2 -A 2Repository: OmniBlocks/scratch-vm Length of output: 312 🏁 Script executed: # Check the imports at the top of snapshot.js
head -50 src/serialization/snapshot.jsRepository: OmniBlocks/scratch-vm Length of output: 1657 🏁 Script executed: # Also verify the exact API that requestUpdateMonitor expects by looking at its implementation more closely
sed -n '3186,3210p' src/engine/runtime.jsRepository: OmniBlocks/scratch-vm Length of output: 1197 🏁 Script executed: # Check what Map is being used in the runtime.js file
rg -n "^const.*Map|^const.*Immutable|import.*Map" src/engine/runtime.js | head -20Repository: OmniBlocks/scratch-vm Length of output: 145 🏁 Script executed: # Check if MonitorRecord is defined and what it is
rg -n "MonitorRecord" src/engine/runtime.js -B 2 -A 2 | head -30Repository: OmniBlocks/scratch-vm Length of output: 343 🏁 Script executed: # Check if snapshot.js imports Immutable or Map from anywhere
rg -n "require.*immutable|import.*immutable|Map" src/serialization/snapshot.js | head -20Repository: OmniBlocks/scratch-vm Length of output: 143 🏁 Script executed: # Look at how other files in the serialization module handle Maps
rg -n "new Map\|Map({" src/serialization/*.jsRepository: OmniBlocks/scratch-vm Length of output: 167 🏁 Script executed: # Search for Map usage in serialization files more carefully
rg -n "Map(" src/serialization/Repository: OmniBlocks/scratch-vm Length of output: 123 🏁 Script executed: # Also check sb2.js to see how it creates monitor records
rg -n "MonitorRecord" src/serialization/sb2.js -B 2 -A 5Repository: OmniBlocks/scratch-vm Length of output: 849 🏁 Script executed: # Check what MonitorRecord is and how it's defined
cat src/engine/monitor-record.jsRepository: OmniBlocks/scratch-vm Length of output: 678 🏁 Script executed: # Also check how execute.js and blocks.js create Maps for requestUpdateMonitor
rg -n "requestUpdateMonitor" src/engine/execute.js -B 2 -A 8 | head -30Repository: OmniBlocks/scratch-vm Length of output: 523 🏁 Script executed: # Check if snapshot.js imports anything from immutable
cat src/serialization/snapshot.js | grep -E "require|import" | head -20Repository: OmniBlocks/scratch-vm Length of output: 278 🏁 Script executed: # Check how runtime.js defines and imports Map
rg -n "Map" src/engine/runtime.js | head -20Repository: OmniBlocks/scratch-vm Length of output: 1331 🏁 Script executed: # Check if Immutable is imported anywhere in execute.js to see how Map is used there
head -30 src/engine/execute.js | grep -E "require|import|Map"Repository: OmniBlocks/scratch-vm Length of output: 321 🏁 Script executed: # Verify the exact usage in execute.js
sed -n '1,10p' src/engine/execute.jsRepository: OmniBlocks/scratch-vm Length of output: 411 Import Map from immutable and use Map({...}) syntax. At line 257, the code creates a native JavaScript Map, but Change snapshot.js to:
Example fixconst {Map} = require('immutable');
// In restoreMonitorState function:
const monitorRecord = Map({
id: monitorState.id,
value: monitorState.value,
visible: monitorState.visible,
mode: monitorState.mode,
x: monitorState.x,
y: monitorState.y
});🤖 Prompt for AI Agents |
||
|
|
||
| /** | ||
| * Restore thread execution state | ||
| * @param {!Runtime} runtime VM runtime instance | ||
| * @param {!Array} threadStates Array of thread state objects from snapshot | ||
| */ | ||
| const restoreThreadState = function (runtime, threadStates) { | ||
| // Clear existing threads | ||
| runtime.threads = []; | ||
| runtime.threadMap.clear(); | ||
|
|
||
| threadStates.forEach(threadState => { | ||
| const target = runtime.getTargetById(threadState.targetId); | ||
| if (!target) { | ||
| log.warn(`Target with ID ${threadState.targetId} not found for thread restore`); | ||
| return; | ||
| } | ||
|
|
||
| // Create new thread using runtime's _pushThread method | ||
| const thread = runtime._pushThread(threadState.topBlock, target, { | ||
| stackClick: threadState.stackClick, | ||
| updateMonitor: threadState.updateMonitor | ||
| }); | ||
|
|
||
| // Restore stack and stack frames | ||
| thread.stack = [...threadState.stack]; | ||
|
|
||
| // Clear default stack frames and restore from snapshot | ||
| thread.stackFrames.forEach(frame => Thread._StackFrame.release(frame)); | ||
| thread.stackFrames = threadState.stackFrames.map(frameState => { | ||
| const frame = Thread._StackFrame.create(frameState.warpMode); | ||
| frame.isLoop = frameState.isLoop; | ||
| frame.reporting = frameState.reporting; | ||
| frame.reported = frameState.reported; | ||
| frame.waitingReporter = frameState.waitingReporter; | ||
| frame.params = frameState.params ? Object.assign({}, frameState.params) : null; | ||
| frame.executionContext = frameState.executionContext ? | ||
| Object.assign({}, frameState.executionContext) : null; | ||
| return frame; | ||
| }); | ||
|
|
||
| // Restore thread status | ||
| thread.status = threadState.status; | ||
|
|
||
| // Restore warp timer if it existed | ||
| if (threadState.warpTimer) { | ||
| try { | ||
| const Timer = require('../util/timer'); | ||
| thread.warpTimer = new Timer(); | ||
| thread.warpTimer.startTime = threadState.warpTimer.startTime; | ||
| } catch (e) { | ||
| // Timer module might not exist, skip warp timer restoration | ||
| log.warn('Could not restore warp timer:', e.message); | ||
| } | ||
| } | ||
| }); | ||
| }; | ||
|
|
||
| /** | ||
| * Restore timer and clock state | ||
| * @param {!Runtime} runtime VM runtime instance | ||
| * @param {!object} timerState Timer state object from snapshot | ||
| */ | ||
| const restoreTimerState = function (runtime, timerState) { | ||
| // Restore runtime timing | ||
| if (typeof timerState.currentMSecs !== 'undefined') runtime.currentMSecs = timerState.currentMSecs; | ||
| if (typeof timerState.currentStepTime !== 'undefined') runtime.currentStepTime = timerState.currentStepTime; | ||
| if (typeof timerState._lastStepTime !== 'undefined') runtime._lastStepTime = timerState._lastStepTime; | ||
|
|
||
| // Restore clock device state | ||
| if (runtime.ioDevices && runtime.ioDevices.clock) { | ||
| if (typeof timerState.projectTimer !== 'undefined') runtime.ioDevices.clock._projectTimer = timerState.projectTimer; | ||
| if (typeof timerState.paused !== 'undefined') runtime.ioDevices.clock._paused = timerState.paused; | ||
| if (typeof timerState.pausedTime !== 'undefined') runtime.ioDevices.clock._pausedTime = timerState.pausedTime; | ||
| } | ||
| }; | ||
|
|
||
| /** | ||
| * Restore IO device state (keyboard, mouse, video) | ||
| * @param {!Runtime} runtime VM runtime instance | ||
| * @param {!object} ioDeviceState IO device state object from snapshot | ||
| */ | ||
| const restoreIODeviceState = function (runtime, ioDeviceState) { | ||
| // Restore keyboard state | ||
| if (ioDeviceState.keyboard && runtime.ioDevices && runtime.ioDevices.keyboard) { | ||
| runtime.ioDevices.keyboard._keysPressed = [...ioDeviceState.keyboard.keysPressed]; | ||
| runtime.ioDevices.keyboard.lastKeyPressed = ioDeviceState.keyboard.lastKeyPressed; | ||
| } | ||
|
|
||
| // Restore mouse state | ||
| if (ioDeviceState.mouse && runtime.ioDevices && runtime.ioDevices.mouse) { | ||
| runtime.ioDevices.mouse._clientX = ioDeviceState.mouse.clientX; | ||
| runtime.ioDevices.mouse._clientY = ioDeviceState.mouse.clientY; | ||
| runtime.ioDevices.mouse._scratchX = ioDeviceState.mouse.scratchX; | ||
| runtime.ioDevices.mouse._scratchY = ioDeviceState.mouse.scratchY; | ||
| runtime.ioDevices.mouse._isDown = ioDeviceState.mouse.isDown; | ||
| runtime.ioDevices.mouse._buttons = new Set(ioDeviceState.mouse.buttons); | ||
| } | ||
|
|
||
| // Restore video state | ||
| if (ioDeviceState.video && runtime.ioDevices && runtime.ioDevices.video) { | ||
| runtime.ioDevices.video._skinId = ioDeviceState.video.skinId; | ||
| runtime.ioDevices.video._drawable = ioDeviceState.video.drawable; | ||
| runtime.ioDevices.video._ghost = ioDeviceState.video.ghost; | ||
| } | ||
| }; | ||
|
|
||
| module.exports = { | ||
| serialize, | ||
| deserialize | ||
| }; | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Significant coupling to private implementation details.
The serialize function extensively accesses underscore-prefixed private properties across multiple components:
runtime._monitorState(private)clock._projectTimer,_paused,_pausedTime(private)keyboard._keysPressed,lastKeyPressed(private)mouse._clientX,_clientY,_scratchX,_scratchY,_isDown,_buttons(private)video._skinId,_drawable,_ghost(private)This creates tight coupling to internal implementation details that may change without notice, breaking snapshot compatibility.
Consider:
device.getState()/device.setState()).The current approach is pragmatic for an initial implementation, but plan for a more robust solution if this feature is heavily used or needs long-term snapshot compatibility.
🤖 Prompt for AI Agents