Modification of a read-only value attempted

During the last few days, I was refactoring a lot of code. I was basically pulling some sets of common code we used in various slightly different implementations in various projects into one generic set of modules to be used by all projects. I started with code to connect to the database and code that does other various database related tasks we need nearly everywhere, among others creating DBIx::Class schema classes via DBIx::Class::Schema::Loader. A lot of guesswork done in various incoherent places was replaced by well-formed Bread::Board classes, and a lot of scripts where replaced by a generic runner script that uses $0 (i.e. the name of the current script) to look up a service via Bread::Board, and run it.

This setup was working for most scripts, but one (namely the one creating DBIx::Class classes) failed. And it failed rather unhelpful with the following error message

Modification of a read-only value attempted at
local/lib/perl5/DBIx/Class/Schema/Loader/Base.pm line 2211.

Looking at said line, I found this:

open(my $fh, '<:encoding(UTF-8)', $fn)
    or croak "Cannot open '$fn' for reading: $!";
while(<$fh>) {  # this is line 2211
    # do stuff
}

I tried a few things, assuming that something with either $fh or $fn must be wrong, but to no avail. But changing the while into foreach made the code work. So we started to assume that the problem was caused by one of Perl's fancy predefined variables (perldoc perlvar) that was somehow clobbered by something that happened when refactoring the code. Which would also match the error message.

It took me some time to finally see the problem.

while(<$fh>) { ... }

is interpreted as

while (defined($_ = <$fh>)) { ... }

Which means that each line of $fh is assigned to $_. Which breaks badly, if you already assigned something to $_. And as $_ is as global as it can get, this other assignment to $_ can be in a class far, far away.

After a short search, the generic runner script was identified as the culprit. In this script, I added these "smart" lines of code:

foreach ( '_run_service', '_run_plack', 'run' ) {
    next unless $service->can($_);
    $service->$_;
    last;
}

A $service is some class initiated via Bread::Board. We decided that all classes that implement something that can actually run, have to implemented a run-method. For backward compatibility, there are some other potential entry points (i.e. _run_service, _run_plack). I iterate through all of those, and if the service implements on, I'll call it.

By now you will clearly see the problem: This loop uses $_, so $_ is set to e.g. 'run'. And it stays set to that, even deep in the bowels of DBIx::Class::Schema::Loader.

The quick fix

Instead of using $_, I used a proper variable, aptly named $method:

foreach my $method ( '_run_service', '_run_plack', 'run' ) {
    next unless $service->can($method);
    $service->$method;
    last;
}

The better fix (I think)

Instead of just fixing my code, it would be better to fix DBIx::Class::Schema::Loader. So I submitted a bug report.

Easily reproducable example

If you want to try it at home:

use strict;
use warnings;
use 5.010;

foreach (qw(a b c)) {
    open(my $fh, '<', $0) || die $!;
    while (<$fh>) {
        say $_;
    }
}

Update: pepl investigates even more

After posting this, pepl flooded the #Austria.pm channel with Devel::Peek dumps, which I'm happy to reproduce here:

If you define the outer list (qw(a b c)) as an array, $_ is not set readonly:

use strict;
use warnings;
use 5.010;

my @list = (qw(a b c));
foreach (@list) {
    open(my $fh, '<', $0) || die $!;
    while (<$fh>) {
        say $_;
    }
}

If you define the list inline, as I did, $_ looks like:

SV = PV(0x2568cd0) at 0x2586968
  REFCNT = 2
  FLAGS = (PADTMP,POK,READONLY,pPOK)
  PV = 0x25a17a0 "1"\0
  CUR = 1
  LEN = 16

Note the READONLY flag

But in you use an array (@list), $_ looks like this:

SV = PV(0xba5a50) at 0xba4cb8
  REFCNT = 2
  FLAGS = (POK,pPOK)
  PV = 0xbbcd00 "1"\0
  CUR = 1
  LEN = 16

No READONLY-Flag

pepl also found this post on Perlmonks, which explains this and other causes for the error I encountered.