import HostInteropType from "../interop"; import * as debuggerProxy from "./HostDebuggerExtensionProxy"; import {default as BreakpointDecoratorManager, Breakpoint} from "./BreakpointDecoratorManager"; /* * Duktape debugger web client * * Talks to the NodeJS server using socket.io. * * http://unixpapa.com/js/key.html */ export default class DuktapeDebugger { // Monaco editor editor: monaco.editor.IStandaloneCodeEditor; socket: SocketIOClient.Socket; breakpointDecorator: BreakpointDecoratorManager; // Update interval for custom source highlighting. SOURCE_UPDATE_INTERVAL = 350; // Source view activeFileName = null; // file that we want to be loaded in source view activeLine = null; // scroll to line once file has been loaded activeHighlight = null; // line that we want to highlight (if any) loadedFileName = null; // currently loaded (shown) file loadedLineCount = 0; // currently loaded file line count loadedFileExecuting = false; // true if currFileName (loosely) matches loadedFileName loadedLinePending = null; // if set, scroll loaded file to requested line highlightLine = null; // highlight line sourceEditedLines = []; // line numbers which have been modified // (added classes etc, tracked for removing) sourceUpdateInterval = null; // timer for updating source view sourceFetchXhr = null; // current AJAX request for fetching a source file (if any) forceButtonUpdate = false; // hack to reset button states bytecodeDialogOpen = false; // bytecode dialog active bytecodeIdxHighlight = null; // index of currently highlighted line (or null) bytecodeIdxInstr = 0; // index to first line of bytecode instructions // Execution state prevState = null; // previous execution state ("paused", "running", etc) prevAttached = null; // previous debugger attached state (true, false, null) currFileName = null; // current filename being executed currFuncName = null; // current function name being executed currLine = 0; // current line being executed currPc = 0; // current bytecode PC being executed // current execution state ("paused", "running", "detached", etc) currState: "connected" | "disconnected" | "running" | "paused" | "reconnecting"; currAttached = false; // current debugger attached state (true or false) currLocals = []; // current local variables currCallstack = []; // current callstack (from top to bottom) currBreakpoints: Breakpoint[] = []; // current breakpoints startedRunning = 0; // timestamp when last started running (if running) // (used to grey out the source file if running for long enough) /* * Helpers */ formatBytes(x) { if (x < 1024) { return String(x) + " bytes"; } else if (x < 1024 * 1024) { return (x / 1024).toPrecision(3) + " kB"; } else { return (x / (1024 * 1024)).toPrecision(3) + " MB"; } } /* * Source view periodic update handling */ doSourceUpdate() { var elem; // Remove previously added custom classes this.sourceEditedLines.forEach((linenum) => { elem = $("#source-code div")[linenum - 1]; if (elem) { elem.classList.remove("breakpoint"); elem.classList.remove("execution"); elem.classList.remove("highlight"); } }); this.sourceEditedLines.length = 0; // If we"re executing the file shown, highlight current line if (this.loadedFileExecuting) { this.editor.revealLineInCenterIfOutsideViewport(this.currLine); this.editor.setPosition(new monaco.Position(this.currLine, 0)); this.sourceEditedLines.push(this.currLine); elem = $("#source-code div")[this.currLine - 1]; if (elem) { //sourceEditedLines.push(currLine); elem.classList.add("execution"); } } // Add breakpoints this.breakpointDecorator.setCurrentFileName(this.loadedFileName); this.currBreakpoints.forEach((bp) => { if (bp.fileName === this.loadedFileName) { this.breakpointDecorator.addBreakpointDecoration(bp.fileName, bp.lineNumber); this.sourceEditedLines.push(bp.lineNumber); } }); if (this.highlightLine !== null) { elem = $("#source-code div")[this.highlightLine - 1]; if (elem) { this.sourceEditedLines.push(this.highlightLine); elem.classList.add("highlight"); } } // Bytecode dialog highlight if (this.loadedFileExecuting && this.bytecodeDialogOpen && this.bytecodeIdxHighlight !== this.bytecodeIdxInstr + this.currPc) { if (typeof this.bytecodeIdxHighlight === "number") { $("#bytecode-preformatted div")[this.bytecodeIdxHighlight].classList.remove("highlight"); } this.bytecodeIdxHighlight = this.bytecodeIdxInstr + this.currPc; $("#bytecode-preformatted div")[this.bytecodeIdxHighlight].classList.add("highlight"); } // If no-one requested us to scroll to a specific line, finish. if (this.loadedLinePending == null) { return; } // Scroll to the requested line var reqLine = this.loadedLinePending; this.loadedLinePending = null; this.editor.revealLineInCenterIfOutsideViewport(reqLine); this.editor.setPosition(new monaco.Position(reqLine, 0)); debuggerProxy.notifyHostCurrentSourcePosition("Resources/" + this.loadedFileName, reqLine); } /* * UI update handling when exec-status update arrives */ doUiUpdate() { var now = Date.now(); // Note: loadedFileName can be either from target or from server, but they // must match exactly. We could do a loose match here, but exact matches // are needed for proper breakpoint handling anyway. this.loadedFileExecuting = (this.loadedFileName === this.currFileName); // If we just started running, store a timestamp so we can grey out the // source view only if we execute long enough (i.e. we"re not just // stepping). if (this.currState !== this.prevState && this.currState === "running") { this.startedRunning = now; } // If we just became paused, check for eval watch if (this.currState !== this.prevState && this.currState === "paused") { if ($("#eval-watch").is(":checked")) { this.submitEval(); // don"t clear eval input } } // Update current execution state if (this.currFileName === "" && this.currLine === 0) { $("#current-fileline").text(""); } else { $("#current-fileline").text(String(this.currFileName) + ":" + String(this.currLine)); } if (this.currFuncName === "" && this.currPc === 0) { $("#current-funcpc").text(""); } else { $("#current-funcpc").text(String(this.currFuncName) + "() pc " + String(this.currPc)); } $("#current-state").text(String(this.currState)); // Update buttons if (this.currState !== this.prevState || this.currAttached !== this.prevAttached || this.forceButtonUpdate) { $("#stepinto-button").prop("disabled", !this.currAttached || this.currState !== "paused"); $("#stepover-button").prop("disabled", !this.currAttached || this.currState !== "paused"); $("#stepout-button").prop("disabled", !this.currAttached || this.currState !== "paused"); $("#resume-button").prop("disabled", !this.currAttached || this.currState !== "paused"); $("#pause-button").prop("disabled", !this.currAttached || this.currState !== "running"); $("#attach-button").prop("disabled", this.currAttached); if (this.currAttached) { $("#attach-button").removeClass("enabled"); } else { $("#attach-button").addClass("enabled"); } $("#detach-button").prop("disabled", !this.currAttached); $("#eval-button").prop("disabled", !this.currAttached); $("#add-breakpoint-button").prop("disabled", !this.currAttached); $("#delete-all-breakpoints-button").prop("disabled", !this.currAttached); $(".delete-breakpoint-button").prop("disabled", !this.currAttached); $("#putvar-button").prop("disabled", !this.currAttached); $("#getvar-button").prop("disabled", !this.currAttached); $("#heap-dump-download-button").prop("disabled", !this.currAttached); } if (this.currState !== "running" || this.forceButtonUpdate) { // Remove pending highlight once we"re no longer running. $("#pause-button").removeClass("pending"); $("#eval-button").removeClass("pending"); } this.forceButtonUpdate = false; // Make source window grey when running for a longer time, use a small // delay to avoid flashing grey when stepping. if (this.currState === "running" && now - this.startedRunning >= 500) { $("#source-pre").removeClass("notrunning"); $("#current-state").removeClass("notrunning"); } else { $("#source-pre").addClass("notrunning"); $("#current-state").addClass("notrunning"); } // Force source view to match currFileName only when running or when // just became paused (from running or detached). var fetchSource = false; if (typeof this.currFileName === "string") { if (this.currState === "running" || (this.prevState !== "paused" && this.currState === "paused") || (this.currAttached !== this.prevAttached)) { if (this.activeFileName !== this.currFileName) { fetchSource = true; this.activeFileName = this.currFileName; this.activeLine = this.currLine; this.activeHighlight = null; this.requestSourceRefetch(); } } } // Force line update (scrollTop) only when running or just became paused. // Otherwise let user browse and scroll source files freely. if (!fetchSource) { if ((this.prevState !== "paused" && this.currState === "paused") || this.currState === "running") { this.loadedLinePending = this.currLine || 0; } } } deleteAllBreakpoints(notifyHost = true) { this.socket.emit("delete-all-breakpoints"); this.breakpointDecorator.clearBreakpointDecorations(); if (notifyHost) { debuggerProxy.removeAllBreakpoints(); } } addBreakpoint(fileName: string, lineNumber: number, notifyHost = true) { fileName = this.fixBreakpointFilename(fileName); this.breakpointDecorator.addBreakpointDecoration(fileName, lineNumber); this.socket.emit("add-breakpoint", { fileName, lineNumber }); if (notifyHost) { debuggerProxy.addBreakpoint(fileName, lineNumber); } } toggleBreakpoint(fileName: string, lineNumber: number, notifyHost = true) { fileName = this.fixBreakpointFilename(fileName); this.breakpointDecorator.toggleBreakpoint(fileName, lineNumber); this.socket.emit("toggle-breakpoint", { fileName, lineNumber }); if (notifyHost) { debuggerProxy.toggleBreakpoint(fileName, lineNumber); } } removeBreakpoint(fileName: string, lineNumber: number, notifyHost = true) { fileName = this.fixBreakpointFilename(fileName); this.breakpointDecorator.removeBreakpointDecoration(fileName, lineNumber); this.socket.emit("delete-breakpoint", { fileName, lineNumber }); if (notifyHost) { debuggerProxy.removeBreakpoint(fileName, lineNumber); } } pause() { this.socket.emit("pause", {}); // Pause may take seconds to complete so indicate it is pending. $("#pause-button").addClass("pending"); } resume() { this.socket.emit("resume", {}); } stepInto() { this.socket.emit("stepinto", {}); setTimeout(() => this.doSourceUpdate(), 125); } stepOut() { this.socket.emit("stepout", {}); setTimeout(() => this.doSourceUpdate(), 125); } stepOver() { this.socket.emit("stepover", {}); setTimeout(() => this.doSourceUpdate(), 125); } initSocket() { /* * Init socket.io and add handlers */ this.socket = io.connect("http://localhost:9092"); // returns a Manager setInterval(() => { this.socket.emit("keepalive", { userAgent: (navigator || {} as Navigator).userAgent }); }, 30000); this.socket.on("connect", () => { $("#socketio-info").text("connected"); this.currState = "connected"; this.fetchSourceList(); }); this.socket.on("disconnect", () => { $("#socketio-info").text("not connected"); this.currState = "disconnected"; }); this.socket.on("reconnecting", () => { $("#socketio-info").text("reconnecting"); this.currState = "reconnecting"; }); this.socket.on("error", (err) => { $("#socketio-info").text(err); }); this.socket.on("replaced", () => { // XXX: how to minimize the chance we"ll further communciate with the // server or reconnect to it? socket.reconnection()? // We"d like to window.close() here but can"t (not allowed from scripts). // Alert is the next best thing. alert("Debugger connection replaced by a new one, do you have multiple tabs open? If so, please close this tab."); }); this.socket.on("keepalive", (msg) => { // Not really interesting in the UI // $("#server-info").text(new Date() + ": " + JSON.stringify(msg)); }); this.socket.on("basic-info", (msg) => { $("#duk-version").text(String(msg.duk_version)); $("#duk-git-describe").text(String(msg.duk_git_describe)); $("#target-info").text(String(msg.target_info)); $("#endianness").text(String(msg.endianness)); }); this.socket.on("exec-status", (msg) => { // Not 100% reliable if callstack has several functions of the same name if (this.bytecodeDialogOpen && (this.currFileName != msg.fileName || this.currFuncName != msg.funcName)) { this.socket.emit("get-bytecode", {}); } this.currFileName = msg.fileName; this.currFuncName = msg.funcName; this.currLine = msg.line; this.currPc = msg.pc; this.currState = msg.state; this.currAttached = msg.attached; // Duktape now restricts execution status updates quite effectively so // there"s no need to rate limit UI updates now. this.doUiUpdate(); this.prevState = this.currState; this.prevAttached = this.currAttached; }); // Update the "console" output based on lines sent by the server. The server // rate limits these updates to keep the browser load under control. Even // better would be for the client to pull this (and other stuff) on its own. this.socket.on("output-lines", (msg) => { var elem = $("#output"); var i, n, ent; elem.empty(); for (i = 0, n = msg.length; i < n; i++) { ent = msg[i]; if (ent.type === "print") { elem.append($("
").text(ent.message)); } else if (ent.type === "alert") { elem.append($("").text(ent.message)); } else if (ent.type === "log") { elem.append($("").text(ent.message)); } else if (ent.type === "debugger-info") { elem.append($("