Strange behavior when using try/catch to return a value from a Class method

Hi all,
In a class method, if you enclose the return within a try/catch block, then if afterwards you call a nonexistent method of a class (this one or any other), the compiler will not call an error, and will simply stop executing code.
I think this situation merits feedback from the compiler or syntax checker.
Minimal demo example below:

/*

save this file as TryReturnTester.sc

then execute:
LanguageConfig.addIncludePath(thisProcess.nowExecutingPath.dirname);
thisProcess.recompile;

then create an instance of the class:
a=TryReturnTester.new

then run the code block below, which behaves as expected
(
a.foo; //no error or syntax issue reported
didfdifhdjif; // ERROR: Variable 'didfdifhdjif' not defined. (as expected)
)

then run the code block below, which doesn't give an error where expected
(
a.foo; //no error or syntax issue reported
a.doesntexit; // no error reported. silently fails and halts further execution
"are we still running?".postln; //doesn't get executed
)

this issue exists if you call a nonexistent method of a different class, as well:
(
a.foo; //no error or syntax issue reported
Array.doesntexit; // no error reported. silently fails and halts further execution
"are we still running?".postln; //doesn't get executed
)

*/

TryReturnTester {
	*new {
		^super.new.init
	}

	init {
	}

	foo {
		try {
			"foo has been executed".postln;
			^true;
		}{
			"ERROR".error;
		};
	}
}

When you use ^true inside try in `foo, it corrupts the error handling mechanism for that code block.

One quick solution I see:

a) don’t return from inside try blocks (just don’t use it)
b) use protect instead (no conflict with error handling mechanism)


	foo {
		^protect {
			"foo has been executed".postln;
			true
		}
	}

Hm… the block/break mechanism depends on ^-returning from within a function, so we can’t just disallow returns within functions. (Also there must be hundreds of methods in the class library that use ^ within functions for loop early exits, e.g., detect.)

Maybe the compiler could recognize ^ within try or protect specifically…?

I agree with smoge on both points.

hjh

This is a well known issue, it is just that no one quite knows what to do about it.

I wonder if there is an issue with the return from method function? Just haven’t had the time to check it out.

1 Like

Thanks @smoge, I personally will of course never return from within try again, now that I’ve spent hours hunting down what was corrupting my code execution, in a much larger and complicated program.

My hope is that we may be able to spare future users this experience by checking for this at the compiler or syntax level. In my opinion, error handling and reporting is by far SC’s biggest area of improvement and this is one example of that.

1 Like

Recognizing ^ within try and bringing up an error would seem like a great approach to me. Since it breaks things to do it, it would seem that there would be no harm in disallowing it entirely.

there-s already a note about it in the Help-File for Exception
https://doc.sccode.org/Classes/Exception.html#kw_try%20protect%20catch

and it-s also mentioned in Function (scroll up for ā€œException Handlingā€):
https://doc.sccode.org/Classes/Function.html#-try

So the issue is that SC doesn’t know about ā€˜try’ at the compiler/parser level. SC has very few true keywords (words that are known at that level, while is perhaps the only one). Try and other exception based methods are actually no different from other methods, they use the ^ syntax to implement their features. Making the parser/compiler know about these methods means lowering them to that level, this is major language change, both in terms of code changes, but also in design philosophy.

I still think this issue can be fixed.

In my opinion, these notes don’t quite capture what I’m pointing out here. They point out that an exception caused by the return function can’t be caught; but the issue I’m pointing out is that even without an exception in the return function, exceptions thereafter, of a particular kind, don’t get called, halting program flow without any feedback. This is a pretty subtle issue to figure out in certain scenarios.

I wish I understood better how the compiler and parser work in SC, so appreciate your insight here.

One thing to note is that my demo code above shows that it’s only certain kinds of exceptions that are muted following the return-within-try, specifically calls to nonexistent class methods (there may be other kinds of exception that get muted too). Since basic exceptions still do get called (like my didfdifhdjif; above), maybe there’s something to figure out here about the difference between these two types of exception?

It’s perhaps simpler than you think: as far as the compiler is concerned, there is no such thing as try. It’s just a method call, that happens to do something sneaky which gives us error handling provided that you use it within acceptable bounds. But it is only a method call.

What Jordan means by a change in language design or philosophy is that, once the compiler introduces special compiler cases for specific method selectors and receiver types, then it may be impossible in some cases to override that behavior in the class library.

Also here’s an example where the compiler couldn’t help you:

SomeClass {
    fu {
        var f = { "bar".postln; ^this };
        f.try { ... error handling ... };
    }
}

The f.try expression – the compiler can’t guarantee what is f – in this case it’s trivial, but if f is assigned from branches, or somehow passed in from another method somewhere, the compiler can’t be sure that f will or won’t contain a ^.

You’re not at all wrong that it’s a terribly confusing trap to fall into, but I’m not sure there’s a 100% ironclad compiler solution.

hjh

1 Like

That all makes good sense, @jamshark70, thanks. Hopefully there’s some way forward here, but I get the difficulty. And in any case, I personally have learned my lesson on this topic :slight_smile:

Yes, of course.

Just an idea: since File.use already exists in SC, why not apply this same model (for acquire-release patterns)? It naturally avoids the ā€œreturn inside try bugā€ because the return happens at the method level, not inside the block.

For reference, this is File.use from the Standard Library:

File {
	*use { arg pathName, mode, function;
		var file;
		file = this.new(pathName, mode);
		^{ function.value(file) }.protect({ file.close });
	}
}

Some benefits:

  • Idiomatic - SC users know use
  • Prevents leaks - cleanup even with errors
  • Composable

Hey yo

Following up on my earlier use suggestion - been in the code lab working on this a bit. Why not something that flips the try/return corruption on its head?

Here’s the move: SafeTry makes corruption structurally impossible. The pattern wraps your code in protect, runs try/catch inside, and keeps returns at the method level


foo {
    ^SafeTry.use(
        { doSomething(); result },  
        { |err| fallback }         
    ) 
}

Under the Hood

The implementation’s got layers, each plays its part, never scattered:

*use { |tryFunc, catchFunc, finallyFunc|
    var result, error, success = false, thread = thisThread, handlerId = UniqueID.next;

    activeHandlers[thread] = activeHandlers[thread] ? IdentitySet.new;
    activeHandlers[thread].add(handlerId);

    if (validatePatterns) {
        this.prValidateFunction(tryFunc)
    };

    protect {
        try {
            result = tryFunc.value;
            success = true;
        } { |err|
            error = err;
            if (debugMode) {
                this.prLogError(err)
            };
            if (catchFunc.notNil) {
                result = catchFunc.value(err)
            } {
                err.throw
            }
        }
    } {
        finallyFunc.value;
        activeHandlers[thread].remove(handlerId);
        if (activeHandlers[thread].isEmpty) {
            activeHandlers.removeAt(thread)
        }
    };

    ^result
}

The key: exceptions are detected and wrapped in protect for guaranteed protection. No early returns means no corruption—that’s the foundation.


foo {
    ^SafeTry.use(
        { if(condition) { earlyReturn } { normalReturn } },  
        { |err| fallback }
    )
}

// with cleanup 
foo {
    var resource;
    ^SafeTry.use(
        { 
            resource = allocateResource();
            processResource(resource)
        },
        { |err| 
            "Error: %".format(err).postln;
            defaultValue
        },
        { 
            resource.free  // always runs
        }
    )
}

Debug mode drops knowledge:

SafeTry.debugMode = true;

SafeTry.use({ nil.squared }, { "handled".postln });

Pattern validation for code inspection:

SafeTry.validatePatterns = true;

Code scanner finds issues across your collection:

SafeTryAnalyzer.scanAll(verbose: true);
// Scanning for unsafe patterns...

Each thread maintains isolated handler tracking:


fork { 
    SafeTry.use(
        { 1.wait; "Thread 1".postln },
        { |err| "Thread 1 error".postln }
    )
};

fork { 
    SafeTry.use(
        { 0.5.wait; Error("oops").throw },
        { |err| "Thread 2 caught: %".format(err).postln }
    )
};

Sugar for the Code

{ nil.squared }.safeTry({ |err| "caught!".postln; 0 });
// → "caught!"
// → 0

{ nil.squared }.safeValue(42);
// → 42

Examples in the Wild

// These actually throw errors (unlike division by zero in SC)
SafeTry.use({ nil.squared }, { |err| "recovered" });
// → "recovered"

SafeTry.use({ Error("oops").throw }, { |err| -1 });
// → -1

// Array access doesn't error, but method calls on nil do:
SafeTry.use({ [1,2,3][10].squared }, { |err| 0 });
// ERROR: Message 'squared' not understood
// → 0

Performance - The Real Numbers

Yeah, there’s overhead, but exception paths ain’t your hot code paths.

(
"Standard try:".postln;
bench { 10000.do { try { 1+1 } { 0 } } }.postln;
"SafeTry:".postln;
bench { 10000.do { SafeTry.try({ 1+1 }, { 0 }) } }.postln;
"SafeTry with validation:".postln;
SafeTry.validatePatterns = true;
bench { 10000.do { SafeTry.try({ 1+1 }, { 0 }) } }.postln;
SafeTry.validatePatterns = false;
)
/*
Standard try:
time to run: 0.0059267319998071 seconds.
0.0059267319998071
SafeTry:
time to run: 0.044450269999743 seconds.
0.044450269999743
SafeTry with validation:
time to run: 0.050068274000296 seconds.
0.050068274000296
*/.

open questions

  • No early returns disrupt
  • Pattern detection stays basic
  • It’s a workaround, not a serious redesign
  • Scanner might miss what’s dynamic
  • Can’t fix the core language blind spots
  • Though it makes your error handling less tragic

Feedback

Drop science if you’ve got experience with this. @dantepfer, I would love to hear if this approach holds up in your code

Stay safe

Hi @smoge, thanks much for this! I just tested it with the example code I included in my initial post in this thread, and unfortunately SafeTry exhibits exactly the same behavior as ā€˜try’ (I won’t repeat that behavior here — it’s identical to what I describe happens with ā€˜try’).

If I’m reading smoge’s intent correctly, I think he’s trying to create a mechanism where method-return within try would feel unidiomatic and users would naturally avoid it. That is, with vanilla SC syntax, ^ within a do loop is harmless but within try is deadly, and it isn’t clear from the syntax why that would be. By creating a syntactic difference, it may be easier to distinguish them and set up ā€œno early returnsā€ as a best practice.

@smoge one refinement may be to disallow concrete return values from SafeTry. There is at least one bug (with try inside a loop) where the return value is unreliable. My opinion is that constructions like x = try { ... } should be avoided for this reason – you might get away with it sometimes, but then crash into this bug in another case.

hjh

1 Like

So I spent a while debugging loops inside try blocks, like the max example on GitHub. Turns out, the code returns inside the exception handler correctly, but when it leaves the try method, it then returns back to the loop.

I’m not 100%, but I think this is an issue with jump byte codes, as they increment the instruction pointer, meaning the next instruction in memory isn’t the next logical instruction, as that would be the return. Exactly how to fix this though, I am unsure about. Perhaps the frame needs to store some extra information about the instruction?

Truth. It’s essentially a wrapper that adds features around try/catch, not a fix for the return-inside-try issue. It’s like putting a safety rail around a hole, while adding some debug and handling features, and maybe creating an idiomatic solution.

Anyway, as I mentioned earlier, it does not address any core language issues.

1 Like

James, I got what you said, and I think that it would make it more useful. By removing the ability to return values, it ā€œlimitsā€ error handling capabilities, but it would prevent two classes of bugs at once. By design, it would force cleaner separation of error handling and value computation

James and all,

Just another idea: make the ā€œdon’t use nonlocal return inside handlersā€ rule visible while you type.

A linter could indicate the error with a wavy underline under the ^ the moment you write it inside a guarded block.

How it would work:

  • open a .sc file
  • Type: {}.try { ^42 }

You should see a colorful wavy underline under the ^ (Flymake warning: ^ inside try block)

The parser can’t reliably forbid ^ there, but we can lint the typical case: a block immediately following one of those selectors that contains a nonlocal return.

;;; sclang-return-guard-simple.el --- Proof-of-concept/simple linter -*- lexical-binding: t; -*-

;; usage:
;; (require 'sclang-return-guard-simple)
(require 'flymake)

(defgroup sclang-return-guard nil
  "Lint for nonlocal returns."
  :group 'languages)

(defcustom sclang-return-guard-selectors '("try" "protect" "use")
  "Selectors to check."
  :type '(repeat string)
  :group 'sclang-return-guard)

(defun sclang-return-guard-check-buffer ()
  "Check buffer for problematic returns."
  (let ((diagnostics '()))
    (save-excursion
      (goto-char (point-min))
      (while (re-search-forward "\\(try\\|protect\\|use\\)[[:space:]]*{" nil t)
        (let ((method-name (match-string 1))
              (block-start (1- (point))))  
          (goto-char block-start)
          (when (eq (char-after) ?{)
            (condition-case nil
                (progn
                  (forward-sexp)  ; Jump to matching }
                  (let ((block-end (point)))
                    (goto-char (1+ block-start))
                    (while (search-forward "^" block-end t)
                      (let ((caret-pos (1- (point))))
                        (unless (save-excursion
                                  (goto-char caret-pos)
                                  (or (nth 3 (syntax-ppss))  ; in string
                                      (nth 4 (syntax-ppss)))) ; in comment
                          (push (flymake-make-diagnostic
                                 (current-buffer)
                                 caret-pos
                                 (1+ caret-pos)
                                 :warning
                                 (format "^ inside %s block" method-name))
                                diagnostics))))))
              (error nil))))))
    diagnostics))

(defun sclang-return-guard-backend (report-fn &rest _args)
  "Flymake backend."
  (funcall report-fn (sclang-return-guard-check-buffer)))


;;;###autoload
(define-minor-mode sclang-return-guard-mode
  "Simple return guard."
  :lighter " ^guard"
  (if sclang-return-guard-mode
      (progn
        (add-hook 'flymake-diagnostic-functions
                  #'sclang-return-guard-backend nil t)
        (when (fboundp 'flymake-mode)
          (flymake-mode 1)))
    (remove-hook 'flymake-diagnostic-functions
                 #'sclang-return-guard-backend t)))

;;;###autoload
(defun sclang-return-guard-auto-enable ()
  "Auto-enable for SuperCollider files."
  (when (and (stringp buffer-file-name)
             (string-match "\\.\\(scd\\|sc\\)\\'" buffer-file-name))
    (sclang-return-guard-mode 1)))

(add-hook 'find-file-hook #'sclang-return-guard-auto-enable)

(provide 'sclang-return-guard-simple)
;;; sclang-return-guard-simple.el ends here

Future

  • ā€œCode actionā€ (ā€œconvert to return-at-method-levelā€): turn

    try { ^compute() } { |e| ^fallback }
    

    into

    var ret;
    try { ret = compute() } { |e| ret = fallback };
    ^ret
    

NOTE: A tree‑sitter would do the same, but safely and precisely. For our return‑inside‑handler issue, it turns a weak text search into a precise AST rule—and that’s the difference between ā€œworks in my demoā€ and ā€œalways works while you type.ā€