Wednesday, April 2, 2025

What is the Current State?


Now that we're at a breakpoint, we want to be able to see what the current state of the machine is.

I've already added a "tab" for that (State), but I haven't populated it yet.

So we now have two remaining tasks: collect the state from the running "process" and display the state in the State tab of the sidepanel.

Collecting the State

In theory, collecting the state is very easy; in practice it is an exercise in jumping through hoops dealing with remote objects.
            if (stepMode || breakpointLines[lineNo]) {
                breakpointSource = source;
                chrome.runtime.sendMessage({ action: "hitBreakpoint", line: lineNo });
                chrome.debugger.sendCommand(source, "Debugger.evaluateOnCallFrame", { callFrameId: params.callFrames[0].callFrameId, expression: "state" }).then(state => {
                    copyObject(source, {}, state.result, copy => {
                        console.log("state =", copy);
                    })
                });
            } else {

CDP_COLLECT_STATE:cdp-till/plugin/js/service-worker.js

This asks Chrome's "Debugger Domain" to provide us with the value of the variable state in the tab running the TILL program. It does, but what comes back is a Runtime.RemoteObject. This isn't very much use. We need to "pull" this object over here, piece by piece. The first thing is to "copy" an object or array, in copyObject:
function copyObject(source, objsSeen, remoteObj, done) {
    // there is always the possibility of cycles in data structures
    // if we have seen this object before, just say "it's that object"
    // it may or may not have been filled out yet, but for our purposes we are "done"
    if (objsSeen[remoteObj.objectId]) {
        done(objsSeen[remoteObj.objectId]);
        return;
    }

    // Otherwise, create the object now and store it in the map
    // We will be the only person filling it out
    var ret;
    if (remoteObj.subtype == 'array') {
        ret = [];
    } else {
        ret = {};
    }
    objsSeen[remoteObj.objectId] = ret;

    var tracker = new Tracker(done, ret);
    chrome.debugger.sendCommand(source, "Runtime.getProperties", { objectId: remoteObj.objectId }).then(
        props => copyProperties(source, objsSeen, ret, props, tracker)
    );
}

CDP_COLLECT_STATE:cdp-till/plugin/js/service-worker.js

The first step is to detect and eliminate cycles by keeping track of which objects we have already recovered. In testing, this did not get exercised, and I'm not sure it does in simple TILL programs, but it is always a good idea to protect yourself against this kind of infinite loop. objsSeen is initialized in the top-level call above where an empty object ({}) is passed in.

We then create an appropriate "destination" object - either an array if so specified, or else just a vanilla object, recording it immediately in objsSeen, guaranteeing that this is the only time it will be recovered. Everything else is asynchronous, so we create a Tracker object which keeps track of how many properties have been requested and how many have been returned, and then request the object's properties from the API, passing the result to the copyProperties function.
function copyProperties(source, objsSeen, ret, props, tracker) {
    for (var p of props.result) {
        if (p.isOwn) {
            tracker.needOne();
            copyProperty(source, objsSeen, ret, p.name, p.value, tracker);
        }
    }
    tracker.haveOne();
}

CDP_COLLECT_STATE:cdp-till/plugin/js/service-worker.js

The properties returned are the correct ones, but the values may be remote objects or arrays. So we need to go through all of the properties that come back, checking each one individually and requesting to copy any remote objects or arrays that we find. As we request each one, we bump the needOne value on the tracker; when everything is done, we state that we have asked for everything we need, and any time they are all there, the tracker can declare its work done.

The final piece of this jigsaw is the copyProperty method which handles each individual property:
function copyProperty(source, objsSeen, building, prop, remote, tracker) {
    if (remote.type == 'string') {
        building[prop] = remote.value;
        tracker.haveOne();
    } else if (remote.type == 'boolean') {
        building[prop] = remote.value;
        tracker.haveOne();
    } else if (remote.type == 'number') {
        if (prop === 'length' && Array.isArray(building)) {
            // nothing to see here ...
        } else {
            building[prop] = remote.value;
        }
        tracker.haveOne();
    } else if (remote.objectId) {
        copyObject(source, objsSeen, remote, have => {
            building[prop] = have;
            tracker.haveOne();
        });
    } else {
        console.log("how do I copy this?", prop, tracker.tracker, remote);
        tracker.haveOne();
    }
}

CDP_COLLECT_STATE:cdp-till/plugin/js/service-worker.js

This just handles each of the cases, distinguishing between them as best it can. In each case, once the assignment is done, the tracker is notified. Note that in the case of the RemoteObject, the tracker is not notified until the end of the nested asynchronous call. If you put the notification earlier than this, it is possible that the tracker could complete while still waiting for a nested property to be evaluated.

Here is the code for the Tracker:
class Tracker {
    constructor(done, result) {
        this.done = done;
        this.result = result;
        this.need = 1;
        this.have = 0;
    }

    needOne() {
        this.need++;
    }

    haveOne() {
        this.have++;
        if (this.have == this.need) {
            this.done(this.result);
        }
    }
}

CDP_COLLECT_STATE:cdp-till/plugin/js/service-worker.js

Note that it starts by setting need to 1 but have to 0. This corresponds to the extra call of haveOne that happens at the end of copyProperties. Depending on the implementation of the remote calls, it may or may not be the case that we have called have as many times as we have called need before we have finished making all the calls: if that were the case, we would fire the callback prematurely. By setting the initial value of need to 1, we can ensure that we do not fire the callback until we have made that "final" call to haveOne.

You may well ask if we could have done this same thing with promises. I believe we could; the API appears to return a promise if you don't provide a callback. Contrary to the views of most of the community, however, I do not find code with promises in it clear or helpful. It seems to be a way for people to write code in a manner that is familiar to them, rather than one that says explicitly what it means. The same could be said for the class syntactic sugar as well as the "lambda" style of callbacks, and yet I have used those. True, but only sometimes, and much more in the code you see here - where my goal is to make the intent of my code simple, rather than precise. The only modern JavaScript feature I consider an unquestioned good is import/export.

Displaying this

Doing a good job of displaying this nested structure is more that I want to deal with. Let's face it, we're talking about a complex, nested tree structure. So I'm not going to. Instead, I'm going to use another table and unfold the first level of complexity into that, whether that is an object or an array. Of course, if there isn't a "first level of complexity", then I will just show the object. Below that, I'll pack things up in JSON or some such so that everything is there, albeit ugly. I will also throw away fields I don't like.

I'm not going to show it, but I have added a simple three-column table to the state table in sidepanel.html.

Let's kick everything off by finishing off where we left off above, and pass the state that we had so assiduously assembled over to the sidepanel.
                chrome.debugger.sendCommand(source, "Debugger.evaluateOnCallFrame", { callFrameId: params.callFrames[0].callFrameId, expression: "state" }).then(state => {
                    copyObject(source, {}, state.result, copy => {
                        chrome.runtime.sendMessage({ action: "showState", state: copy });
                    })
                });

CDP_DISPLAY_STATE:cdp-till/plugin/js/service-worker.js

This will, of course, invoke our existing onMessage handler in sidepanel.js, so we need to add another case to that:
    case "showState": {
        if (debugMode) {
            showState(request.state);
        }
        break;
    }

CDP_DISPLAY_STATE:cdp-till/plugin/html/js/sidepanel.js

This checks that we are currently in "debug mode" and then calls the code to show the current state in the state table.

This, of course, requires us to obtain the DOM entry for the table:
var statebody = document.getElementById("display-state");

CDP_DISPLAY_STATE:cdp-till/plugin/html/js/sidepanel.js

And then it's just a question of doing all of the mindless DOM operations. The state is an object, i.e. a map, and we want to put the key in the first column of the table and then delegate the other two columns to an appropriate function - showing as an array, an object or JSON:
function showState(state) {
    statebody.innerHTML = '';
    var keys = Object.keys(state);
    keys.sort();
    for (var k of keys) {
        var tr = document.createElement("tr");
        statebody.appendChild(tr);

        var tkey = document.createElement("td");
        tkey.appendChild(document.createTextNode(k));
        tr.appendChild(tkey);

        var v = state[k];
        if (Array.isArray(v)) {
            showArray(v);
        } else if (typeof(v) == 'object') {
            showObject(v);
        } else {
            var ign = document.createElement("td");
            tr.appendChild(ign);
            showJSON(tr, v);
        }
    }
}

CDP_DISPLAY_STATE:cdp-till/plugin/html/js/sidepanel.js

For arrays, we want to show all the members on separate rows below the key, with the var field empty, the index number in the middle column, and the value as JSON:
function showArray(arr) {
    for (var i=0;i<arr.length;i++) {
        var tr = document.createElement("tr");
        statebody.appendChild(tr);

        // leave the "var" column blank
        var ign = document.createElement("td");
        tr.appendChild(ign);

        var idx = document.createElement("td");
        idx.appendChild(document.createTextNode(i));
        tr.appendChild(idx);

        showJSON(tr, arr[i]);
    }
}

CDP_DISPLAY_STATE:cdp-till/plugin/html/js/sidepanel.js

For objects, we want code suspiciously similar to showState but indented one column. If you try and refactor showState and showObject into one piece of code, you will end up with something that basically requires the tree structure that this should really have. It's too much work for me, but if you want to put the effort in and send me a pull request, I'll accept it with full credit.
function showObject(obj) {
    var keys = Object.keys(obj);
    keys.sort();
    for (var k of keys) {
        var tr = document.createElement("tr");
        statebody.appendChild(tr);

        // leave the "var" column blank
        var ign = document.createElement("td");
        tr.appendChild(ign);
        
        var tidx = document.createElement("td");
        tidx.appendChild(document.createTextNode(k));
        tr.appendChild(tidx);

        showJSON(tr, obj[k]);
    }
}

CDP_DISPLAY_STATE:cdp-till/plugin/html/js/sidepanel.js

Finally, showJson shows the JSON representation of a value in the final column. Note that for simple types like strings and numbers, there is no additional ceremony to JSON, so it all just comes out as you would expect. I also take this opportunity to delete the methodCode field, since it doesn't add anything very much. I didn't bother deleting styles, although I'm dubious about its contribution as well.
function showJSON(tr, v) {
    // I don't want to show "methodCode"
    delete v["methodCode"];

    var json = JSON.stringify(v);
    var val = document.createElement("td");
    val.appendChild(document.createTextNode(json));
    tr.appendChild(val);
}

CDP_DISPLAY_STATE:cdp-till/plugin/html/js/sidepanel.js

As one last wheeze, we want to clear all this out as soon as we press "continue", because it will be out of date almost immediately:
function debuggerInactive() {
    debugMode = false;
    breakAt.classList.remove("current-break");
    continueButton.classList.remove("available");
    stepButton.classList.remove("available");
    statebody.innerHTML = '';
}

CDP_DISPLAY_STATE:cdp-till/plugin/html/js/sidepanel.js

Conclusion

Yes, we can retrieve and show the machine state when we hit a breakpoint.

The only thing which is truly interesting here is the call to evaluateOnCallFrame, which was the same method we used before to determine the current line number. It was also good to see how you can copy objects (and arrays) recursively from the application tab to the service worker.

Sending the state to the sidepanel and displaying it was just an exercise in DOM manipulation.

No comments:

Post a Comment