Skip to content
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

add #exc reader to break on exception #769

Merged
merged 4 commits into from
May 7, 2023

Conversation

bo-tato
Copy link
Contributor

@bo-tato bo-tato commented Feb 11, 2023

I added malabarba's idea for simple break on exception from here clojure-emacs/cider#2754 (comment)

Basically when you run something and get an exception, you can click in the error buffer the location in your source closest to the top of the stacktrace, it takes you to the source location, you add a #exc before the form, and rerun whatever caused the exception, now the debugger will break there showing the exception and you can inspect local variables, eval stuff in the local context etc.
screenshot

Of course this is basic and limited, as noted in that issue:

Wrapping the point of exception in a try-form and setting breakpoint on except. Kind of works, but only (1) in your own source code (not on exceptions thrown in libraries) (2) only after you known the position in the code which throws.

this is just a convenience macro for manually wrapping in try form and setting breakpoint on except, but I think the little extra convenience makes it more pleasant. If there's interest in merging this then I'll do the rest to make this a proper pull request (adding tests and documentation)

@bo-tato
Copy link
Contributor Author

bo-tato commented Feb 15, 2023

I made some changes

  1. I call it #exn now cause I realized exn is standard abbreviation for exception among lisps and ocaml at least it seems while exc isn't
  2. I added a #dbgexn reader, that in the same way #dbg will instrument all subforms with #break, #dbgexn will instrument all subforms with #exn (break only on exception)
  3. you can now "continue" or "inject" after exception, let's say you have the code:
(defn g [y]
  (+ 2 y))

#dbgexn
(defn f
  [x]
  (inc (g x)))

(f "a")

You run and the debugger breaks on exception (it breaks in f not in the original source at g because here we just instrumented f for debugging)
screenshot
now we can M-x clone-indirect-buffer to more easily edit the file without triggering the debugger keybindings, edit g to handle strings, for example:

(defn g [y]
  (if (string? y)
    1
    (+ 3 y)))

we eval g, then hit continue in the debugger, and it will continue execution rerunning the (g x) expression, and (f "a") will give 2. Or rather than editing g, at the breakpoint for the exception we could choose inject, and the program will continue running using whatever you injected as the result of (g x) instead of dying with the exception. Really all I wanted was to be able to see local variables at the point of exception, I'm not sure if these features are actually useful it's just ideas to experiment.
4. in CIDER on the elisp side, in cider-eval-defun-at-point I added that if it gets a negative argument it will eval with #dbgexn, the same way that now with any other prefix argument it evals with #dbg. That way when you see you're program is crashing with unhandled exception somewhere in f, you don't have to add and remove reader macros, you can just M-- (negative-argument) and then whatever your keybinding for cider-eval-defun-at-point and rerun and now you will get breakpoint on the form with unhandled exception. Another idea that is beyond my current elisp skills but I might attempt in a few months once I've learned more, is if some cider-eval command crashs with exception, look at the top function in our code from the error report returned, auto instrument it with #dbgexn and then eval again the expression. It shouldn't be fully automatic cause if the user was eval'ing something with side effects it would get run twice, but it maybe just make a separate cider command users could bind, that tries to instrument for break on exception the forms in the error trace and then eval again the previous eval.

This is all just experimental and trying ideas. I'm not a professional programmer and wouldn't dare messing around in debugger internals and code instrumentation in most languages but with lisp code instrumentation is simple enough that I can be dangerous 😃 so any feedback is welcome

@bo-tato
Copy link
Contributor Author

bo-tato commented Feb 26, 2023

I've been using this a bit now and it seems to be working fine as a quick and easy way to inspect local variables and eval in repl at point where exception is thrown. For completeness the only other change I made was on the cider side, in cider-repl.el in the definition of cider-eval-defun-at-point changing:

(when (and debug-it (not inline-debug))
           (concat "#dbg\n" (cider-defun-at-point)))

to

(when (and debug-it (not inline-debug))
                              (if (eq '- debug-it)
                                  (concat "#dbgexn\n" (cider-defun-at-point))
                                (concat "#dbg\n" (cider-defun-at-point))))

@bbatsov
Copy link
Member

bbatsov commented Mar 10, 2023

this is just a convenience macro for manually wrapping in try form and setting breakpoint on except, but I think the little extra convenience makes it more pleasant. If there's interest in merging this then I'll do the rest to make this a proper pull request (adding tests and documentation)

Sorry about the slow response. The idea looks appealing to me, so let's polish it a bit and ship it!

@bbatsov
Copy link
Member

bbatsov commented Mar 10, 2023

This is all just experimental and trying ideas. I'm not a professional programmer and wouldn't dare messing around in debugger internals and code instrumentation in most languages but with lisp code instrumentation is simple enough that I can be dangerous

Lisp is truly special indeed! I took a closer look at the code and it definitely looks good. Seems most professional devs are hesitant to touch the debugger code themselves, so you're a real hero in my book. :-)

@bbatsov
Copy link
Member

bbatsov commented Mar 18, 2023

@bo-tato ping :-)

@bo-tato
Copy link
Contributor Author

bo-tato commented Mar 19, 2023

thanks :) sorry I got busy with work again and haven't had much time for learning and experimenting with clojure. One thing to polish is deciding how exactly it should behave after stopping at exception. One possibility is to have three options, quit, continue, and inject. Inject continues with the value you injected as the return value of the expression that failed with exception. Continue will rerun the expression (presumably you've edited and reloaded some function so it will work now instead of throwing exception again), and quit is self-explanatory. I'm not sure if this is worth it, cause continue is only useful if you can actually edit and reload code, which is hard at present as cider debug keybindings conflict with trying to edit. The way I was able to edit was by using clone-indirect-buffer before starting the debugger, then start the debugger in one window and can edit and reload code in the other, but you have to do that before starting the debugger. Also this would have to be well documented as it could be confusing and have undesired behaviour especially if the expression has side effects, people probably don't expect continue to rerun code that has already run. And when trying to implement this I got errors as I don't understand exactly how instrumentation works on loop/recur forms. So the way I implemented it now is on continue it just tries to rerun once and if that throws an exception again it just crashes with the exception. Or at least that's how I thought it behaved, when I tried just now it seems it actually catches the exception twice before crashing. Which also gives the buggy behaviour that if you do quit it catches the exception "Cannot invoke java.lang.Thread.stop()" and you have to quit again to actually quit.

So maybe the simplest choice is to simply break on exception and have the chance to inspect locals or eval code in that context, and then just exit, without the option to inject or continue. I'm not sure if inject and continue are actually useful in practice, a workflow I liked more was combining this with scope-capture and scope-capture-nrepl. I bound one key to:

(cider-interactive-eval
                             "(spy)")

one key to:

(defun scope-capture-in-ep (ep)
  "sets local variables to given Execution point (from sc.api/spy)
if none provided defaults to the most recent EP"
  (interactive "P")
  (if ep
      (cider-interactive-eval (format "(sc.nrepl.repl/in-ep %s)" ep))
    (cider-interactive-eval "(sc.nrepl.repl/in-ep (first (sc.api/last-ep-id)))")))

and one key to:

(cider-interactive-eval
                             "(sc.nrepl.repl/exit)")

That way when it breaks at exception and it's some complex case I want to edit and re-eval stuff in that context, I can save the local variables with spy, exit the debugger and then use in-ep and then edit and reeval stuff all I want

@bbatsov
Copy link
Member

bbatsov commented Mar 25, 2023

So maybe the simplest choice is to simply break on exception and have the chance to inspect locals or eval code in that context, and then just exit, without the option to inject or continue. I'm not sure if inject and continue are actually useful in practice, a workflow I liked more was combining this with scope-capture and scope-capture-nrepl. I bound one key to:

I'm fine with making this max simple. Something is infinitely better than nothing anyways. :-)

@yuhan0
Copy link
Contributor

yuhan0 commented Apr 3, 2023

Just tried this out and it works wonderfully :)

One small issue - at the moment this does not catch assertions, since the try block only catches java.lang.Exception.
ie. anything thrown with (assert) or the :pre / :post map of a function won't trigger the debugger.

(defn g [y]
 {:pre [(even? y)]}
 (+ 2 y))

#dbgexn
(defn f
 [x]
 (inc (g x)))

(f 1) ; <- throws AssertionError

The wrapping form should probably catch Throwable instead, which both Errors and Exceptions inherit from. I don't think the semantic distinction of 'recoverability' matters here when dealing with interactive bug hunting.

It might even be helpful to implement in the future a debugging mode which automatically wraps all assertions in these #exn forms when evaled, since they are precisely the points in the code where I want to inspect the runtime values of locals which do not conform to some explicit spec.

`(try ~form
(catch Exception ex#
(let [exn-message# (.getMessage ex#)
break-result# (expand-break exn-message# ~dbg-state ~original-form)]
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would also be nice if we could overload the s->:stacktrace and p->:inspect commands here,
using Cider's stacktrace analyser to make better sense of the stacktrace, or the inspector to traverse the ex-info map.

Currently the result being returned here is only exn-message#, so calling the :inspect command simply inspects the error string.

Maybe a better method is to return the exception object ex# itself? On the client end we could process the response to pretty-print the message alone as an overlay. Inspecting would then work automatically, and we could add an condition in debug-stacktrace to analyse the current value if (instance? Throwable value)

@bo-tato
Copy link
Contributor Author

bo-tato commented Apr 26, 2023

I'm fine with making this max simple. Something is infinitely better than nothing anyways. :-)

agreed :) better just make something basic and working and then others or me in the future can improve it. I kept the option of injecting a value as that seems pretty straightforward without problems, but for continue I just made it rethrow the exception.

thanks for feedback @yuhan0 ! I changed it to catch Throwable,
Rather than just returning the error string, I tried returning the exception, it shows as an ugly data structure, in the overlay and the inspector: exception
I tried pretty printing the stack trace and returning that with:

sw# (new java.io.StringWriter)
pw# (new java.io.PrintWriter sw#)
stack-trace# (.printStackTrace ex# pw#)
exn-message# (.toString sw#)

It displays as all one line in the overlay, and the inspector isn't pretty either: printStackTrace
So I think as now just returning the single exception message string is the best simple option. Now if you hit continue it will rethrow the exception, so you will get the full stack trace nicely displayed in the cider-error buffer.

It might even be helpful to implement in the future a debugging mode which automatically wraps all assertions in these #exn forms when evaled, since they are precisely the points in the code where I want to inspect the runtime values of locals which do not conform to some explicit spec.

sounds like a good idea

my clojure-lsp makes some spaces different than the clj-fmt that ci wants
@bo-tato
Copy link
Contributor Author

bo-tato commented Apr 26, 2023

I opened a parallel pull request clojure-emacs/cider#3337
that adds support for it in cider and documents it

@yuhan0
Copy link
Contributor

yuhan0 commented Apr 28, 2023

So I tried returning the exception and parsing it client-side, which allows for using an appropriate cider-overlay-error-face:

image

Here's the modified code in cider-debug

(defun cider--debug-parse-error (value)
  (when (string-match "#error {" value)
    (with-temp-buffer
      (insert value)
      (goto-char (point-min))
      (search-forward-regexp ":cause \"" nil 'noerror)
      (read (thing-at-point 'string)))))

(defun cider--debug-display-result-overlay (value)
  "Place an overlay at point displaying VALUE."
  (when cider-debug-use-overlays
    ;; This is cosmetic, let's ensure it doesn't break the session no matter what.
    (ignore-errors
      ;; Result
      (let ((err (cider--debug-parse-error value)))
        (cider--make-result-overlay (or err (cider-font-lock-as-clojure value))
          :where (point-marker)
          :type 'debug-result
          :prepend-face (if err 'cider-error-overlay-face
                          'cider-result-overlay-face)
          'before-string cider--fringe-arrow-string))
      ;; Code
      (cider--make-overlay (save-excursion (clojure-backward-logical-sexp 1) (point))
                           (point) 'debug-code
                           'face 'cider-debug-code-overlay-face
                           ;; Higher priority than `show-paren'.
                           'priority 2000))))

A side effect of this is any exception objects being debugged normally will also be displayed in this manner, and also I believe the #error {..} representation was only introduced after Clojure 1.7 .
Maybe a more rigorous solution would be to add a separate key to the response map but I couldn't see a simple way this could be achieved.

@yuhan0
Copy link
Contributor

yuhan0 commented Apr 28, 2023

I agree that using continue to rethrow the exception is the easiest option, but there are cases this might not work, eg. if there's an outer try/catch which handles errors, and you want to debug the inner exprs where the exception occurs:

(defn foo [n]
  #exn (inc n))

(try
  (foo nil)
  (catch Exception e
    ,,,))

@bo-tato
Copy link
Contributor Author

bo-tato commented Apr 28, 2023

nice! now that it is returning the full exception, maybe you can also open the cider-error buffer showing it? so then we wouldn't need to rely on continue and rethrowing it to see it in the cider-error buffer which won't work as you say in cases where the code catches it higher up

@yuhan0
Copy link
Contributor

yuhan0 commented May 7, 2023

I've made a few commits on top of this PR in https://github.com/yuhan0/cider-nrepl/tree/debug-exn, with this you can press "s" to bring up the stacktrace viewer, and it adds a :caught-msg key to the response when breaking on exception which the Cider client can use to display an error overlay.

Just to bikeshed a little on the naming - how about #break! and #dbg! - easier to convert to/from the existing ones by changing a single character.

Or they could be metadata keys on top of the existing reader macros, maybe :exn / :on-exn / :on/ex, trading off a bit of verbosity to avoid polluting the global namespace:

#break ^:exn (...) 
#dbg ^{:break/when (pred ...) :on/ex true} (...)

I imagine it would combine disjunctively with the existing :break/when attribute - The last line says "recursively step through these forms if (some invariant is violated) OR if something throws an exception"

@bbatsov
Copy link
Member

bbatsov commented May 7, 2023

Just to bikeshed a little on the naming - how about #break! and #dbg! - easier to convert to/from the existing ones by changing a single character.

I like this idea. Seems more "user-friendly" to me.

At any rate - I'll merge the PR in the current state so @yuhan0 can open a follow-up PR with his improvements.

@bbatsov bbatsov merged commit a53ab41 into clojure-emacs:master May 7, 2023
@yuhan0 yuhan0 mentioned this pull request May 8, 2023
5 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants