Detect and very loudly warn about Return::Multilevel in exception_action()
Peter Rabbitson [Tue, 2 Feb 2016 10:23:04 +0000 (11:23 +0100)]
( cherry-pick of 7cb3585200 )

Changes
lib/DBIx/Class/Schema.pm
lib/DBIx/Class/_Util.pm
t/35exception_inaction.t [new file with mode: 0644]

diff --git a/Changes b/Changes
index 5f78107..49461a7 100644 (file)
--- a/Changes
+++ b/Changes
@@ -6,6 +6,8 @@ Revision history for DBIx::Class
           to be a mostly useless overprotection)
 
     * Fixes
+        - Ensure leaving an exception stack via Return::MultiLevel or something
+          similar produces a large warning
         - Another relatively invasive set of ::FilterColumn changes, covering
           potential data loss (RT#111567). Please run your regression tests!
         - Ensure failing on_connect* / on_disconnect* are dealt with properly,
index ed219b0..84e4e37 100644 (file)
@@ -8,7 +8,7 @@ use base 'DBIx::Class';
 use DBIx::Class::Carp;
 use Try::Tiny;
 use Scalar::Util qw/weaken blessed/;
-use DBIx::Class::_Util qw(refcount quote_sub);
+use DBIx::Class::_Util qw(refcount quote_sub is_exception scope_guard);
 use Devel::GlobalDestruction;
 use namespace::clean;
 
@@ -1055,26 +1055,67 @@ default behavior will provide a detailed stack trace.
 =cut
 
 sub throw_exception {
-  my $self = shift;
+  my ($self, @args) = @_;
 
   if (my $act = $self->exception_action) {
-    if ($act->(@_)) {
-      DBIx::Class::Exception->throw(
+
+    my $guard_disarmed;
+
+    my $guard = scope_guard {
+      return if $guard_disarmed;
+      local $SIG{__WARN__};
+      Carp::cluck("
+                    !!! DBIx::Class INTERNAL PANIC !!!
+
+The exception_action() handler installed on '$self'
+aborted the stacktrace below via a longjmp (either via Return::Multilevel or
+plain goto, or Scope::Upper or something equally nefarious). There currently
+is nothing safe DBIx::Class can do, aside from displaying this error. A future
+version ( 0.082900, when available ) will reduce the cases in which the
+handler is invoked, but this is neither a complete solution, nor can it do
+anything for other software that might be affected by a similar problem.
+
+                      !!! FIX YOUR ERROR HANDLING !!!
+
+This guard was activated beginning"
+      );
+    };
+
+    eval {
+      # if it throws - good, we'll go down to the do{} below
+      # if it doesn't - do different things depending on RV truthiness
+      if( $act->(@args) ) {
+        $args[0] = (
           "Invocation of the exception_action handler installed on $self did *not*"
         .' result in an exception. DBIx::Class is unable to function without a reliable'
         .' exception mechanism, ensure that exception_action does not hide exceptions'
-        ." (original error: $_[0])"
-      );
+        ." (original error: $args[0])"
+        );
+      }
+      else {
+        carp_unique (
+          "The exception_action handler installed on $self returned false instead"
+        .' of throwing an exception. This behavior has been deprecated, adjust your'
+        .' handler to always rethrow the supplied error'
+        );
+      }
+
+      $guard_disarmed = 1;
     }
 
-    carp_unique (
-      "The exception_action handler installed on $self returned false instead"
-    .' of throwing an exception. This behavior has been deprecated, adjust your'
-    .' handler to always rethrow the supplied error.'
-    );
+      or
+
+    do {
+      # We call this to get the necessary warnings emitted and disregard the RV
+      # as it's definitely an exception if we got as far as this do{} block
+      is_exception($@);
+
+      $guard_disarmed = 1;
+      $args[0] = $@;
+    };
   }
 
-  DBIx::Class::Exception->throw($_[0], $self->stacktrace);
+  DBIx::Class::Exception->throw($args[0], $self->stacktrace);
 }
 
 =head2 deploy
index 6ee81a8..091f976 100644 (file)
@@ -25,6 +25,8 @@ BEGIN {
 
     HAS_ITHREADS => $Config{useithreads} ? 1 : 0,
 
+    UNSTABLE_DOLLARAT => ( "$]" < 5.013002 ) ? 1 : 0,
+
     DBICTEST => $INC{"DBICTest/Util.pm"} ? 1 : 0,
 
     # During 5.13 dev cycle HELEMs started to leak on copy
@@ -69,7 +71,8 @@ use base 'Exporter';
 our @EXPORT_OK = qw(
   sigwarn_silencer modver_gt_or_eq modver_gt_or_eq_and_lt
   fail_on_internal_wantarray fail_on_internal_call
-  refdesc refcount hrefaddr is_exception detected_reinvoked_destructor
+  refdesc refcount hrefaddr
+  scope_guard is_exception detected_reinvoked_destructor
   quote_sub qsub perlstring serialize
   UNRESOLVABLE_CONDITION
 );
@@ -115,6 +118,31 @@ sub serialize ($) {
   nfreeze($_[0]);
 }
 
+sub scope_guard (&) {
+  croak 'Calling scope_guard() in void context makes no sense'
+    if ! defined wantarray;
+
+  # no direct blessing of coderefs - DESTROY is buggy on those
+  bless [ $_[0] ], 'DBIx::Class::_Util::ScopeGuard';
+}
+{
+  package #
+    DBIx::Class::_Util::ScopeGuard;
+
+  sub DESTROY {
+    &DBIx::Class::_Util::detected_reinvoked_destructor;
+
+    local $@ if DBIx::Class::_ENV_::UNSTABLE_DOLLARAT;
+
+    eval {
+      $_[0]->[0]->();
+      1;
+    } or do {
+      Carp::cluck "Execution of scope guard $_[0] resulted in the non-trappable exception:\n\n$@";
+    };
+  }
+}
+
 sub is_exception ($) {
   my $e = $_[0];
 
diff --git a/t/35exception_inaction.t b/t/35exception_inaction.t
new file mode 100644 (file)
index 0000000..0d8597f
--- /dev/null
@@ -0,0 +1,102 @@
+use strict;
+use warnings;
+
+use lib 't/lib';
+use DBICTest::RunMode;
+BEGIN {
+  if( DBICTest::RunMode->is_plain ) {
+    print "1..0 # SKIP not running dangerous segfault-prone test on plain install\n";
+    exit 0;
+  }
+}
+
+use File::Temp ();
+use DBIx::Class::_Util 'scope_guard';
+use DBIx::Class::Schema;
+
+# Do not use T::B - the test is hard enough not to segfault as it is
+my $test_count = 0;
+
+# start with one failure, and decrement it at the end
+my $failed = 1;
+
+sub ok {
+  printf STDOUT ("%s %u - %s\n",
+    ( $_[0] ? 'ok' : 'not ok' ),
+    ++$test_count,
+    $_[1] || '',
+  );
+
+  unless( $_[0] ) {
+    $failed++;
+    printf STDERR ("# Failed test #%d at %s line %d\n",
+      $test_count,
+      (caller(0))[1,2]
+    );
+  }
+
+  return !!$_[0];
+}
+
+# yes, make it even dirtier
+my $schema = 'DBIx::Class::Schema';
+
+$schema->connection('dbi:SQLite::memory:');
+
+# this is incredibly horrible...
+# demonstrate utter breakage of the reconnection/retry logic
+#
+open(my $stderr_copy, '>&', *STDERR) or die "Unable to dup STDERR: $!";
+my $tf = File::Temp->new( UNLINK => 1 );
+
+my $output;
+
+ESCAPE:
+{
+  my $guard = scope_guard {
+    close STDERR;
+    open(STDERR, '>&', $stderr_copy);
+    $output = do { local (@ARGV, $/) = $tf; <> };
+    close $tf;
+    unlink $tf;
+    undef $tf;
+    close $stderr_copy;
+  };
+
+  close STDERR;
+  open(STDERR, '>&', $tf) or die "Unable to reopen STDERR: $!";
+
+  $schema->storage->ensure_connected;
+  $schema->storage->_dbh->disconnect;
+
+  local $SIG{__WARN__} = sub {};
+
+  $schema->exception_action(sub {
+    ok(1, 'exception_action invoked');
+    # essentially what Dancer2's redirect() does after https://github.com/PerlDancer/Dancer2/pull/485
+    # which "nicely" combines with: https://metacpan.org/source/MARKOV/Log-Report-1.12/lib/Dancer2/Plugin/LogReport.pm#L143
+    # as encouraged by: https://metacpan.org/pod/release/MARKOV/Log-Report-1.12/lib/Dancer2/Plugin/LogReport.pod#Logging-DBIC-database-queries-and-errors
+    last ESCAPE;
+  });
+
+  # this *DOES* throw, but the exception will *NEVER SHOW UP*
+  $schema->storage->dbh_do(sub { $_[1]->selectall_arrayref("SELECT * FROM wfwqfdqefqef") } );
+
+  # NEITHER will this
+  ok(0, "Nope");
+}
+
+ok(1, "Post-escape reached");
+
+ok(
+  !!( $output =~ /DBIx::Class INTERNAL PANIC.+FIX YOUR ERROR HANDLING/s ),
+  'Proper warning emitted on STDERR'
+) or print STDERR "Instead found:\n\n$output\n";
+
+print "1..$test_count\n";
+
+# this is our "done_testing"
+$failed--;
+
+# avoid tasty segfaults on 5.8.x
+exit( $failed );