From 8d5e7dbfa9e918b30faeae58ac9b7dedad8b37c5 Mon Sep 17 00:00:00 2001 From: Toddr Bot Date: Wed, 1 Apr 2026 02:02:01 +0000 Subject: [PATCH] fix: migrate open file handles to new mock on rename After rename(), open filehandles lost access to data because each handle's weak 'data' reference still pointed to the old mock (whose contents was set to undef). This violated Unix semantics where open fds follow the inode, not the directory entry. The fix re-points each handle's data weakref and file path to the new mock during rename, matching real filesystem behavior. Co-Authored-By: Claude Opus 4.6 --- lib/Test/MockFile.pm | 18 ++++++++ t/rename_open_handle.t | 99 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 117 insertions(+) create mode 100644 t/rename_open_handle.t diff --git a/lib/Test/MockFile.pm b/lib/Test/MockFile.pm index 6c5d4cf..c736f7e 100644 --- a/lib/Test/MockFile.pm +++ b/lib/Test/MockFile.pm @@ -4125,6 +4125,24 @@ sub __rename ($$) { $mock_new->{'mtime'} = $mock_old->{'mtime'}; $mock_new->{'atime'} = $mock_old->{'atime'}; + # Migrate open file handles from old mock to new mock. + # Unix semantics: open fds follow the inode, not the directory entry. + # Each tied FileHandle has a weak 'data' ref that must be re-pointed. + if ( $mock_old->{'fhs'} && @{ $mock_old->{'fhs'} } ) { + $mock_new->{'fhs'} //= []; + for my $fh_ref ( @{ $mock_old->{'fhs'} } ) { + next unless defined $fh_ref; + my $tied = ref $fh_ref ? tied( *{$fh_ref} ) : undef; + if ($tied) { + $tied->{'data'} = $mock_new; + Scalar::Util::weaken( $tied->{'data'} ); + $tied->{'file'} = $mock_new->{'path'}; + } + push @{ $mock_new->{'fhs'} }, $fh_ref; + } + $mock_old->{'fhs'} = []; + } + # rename updates ctime on both source and destination my $now = time; $mock_new->{'ctime'} = $now; diff --git a/t/rename_open_handle.t b/t/rename_open_handle.t new file mode 100644 index 0000000..b0ebb91 --- /dev/null +++ b/t/rename_open_handle.t @@ -0,0 +1,99 @@ +use strict; +use warnings; + +use Test2::Bundle::Extended; +use Test2::Tools::Explain; +use Test2::Plugin::NoWarnings; + +use Test::MockFile qw< nostrict >; + +subtest 'read continues after rename' => sub { + my $old = Test::MockFile->file( '/fake/old.txt', 'Hello World' ); + my $new = Test::MockFile->file( '/fake/new.txt', '' ); + + open( my $fh, '<', '/fake/old.txt' ) or die "open: $!"; + my $buf; + read( $fh, $buf, 5 ); + is( $buf, 'Hello', 'read before rename' ); + + rename( '/fake/old.txt', '/fake/new.txt' ) or die "rename: $!"; + + # Unix semantics: open fd survives rename — data follows the inode. + read( $fh, $buf, 6 ); + is( $buf, ' World', 'read after rename sees remaining data' ); + + close $fh; +}; + +subtest 'write continues after rename' => sub { + my $old = Test::MockFile->file( '/fake/w_old.txt', 'AB' ); + my $new = Test::MockFile->file( '/fake/w_new.txt', '' ); + + open( my $fh, '+<', '/fake/w_old.txt' ) or die "open: $!"; + + rename( '/fake/w_old.txt', '/fake/w_new.txt' ) or die "rename: $!"; + + # Write should go to the new location's data. + print $fh 'XY'; + close $fh; + + is( $new->contents, 'XY', 'write after rename lands in new mock' ); +}; + +subtest 'tell preserved after rename' => sub { + my $old = Test::MockFile->file( '/fake/t_old.txt', 'ABCDEF' ); + my $new = Test::MockFile->file( '/fake/t_new.txt', '' ); + + open( my $fh, '<', '/fake/t_old.txt' ) or die "open: $!"; + my $buf; + read( $fh, $buf, 3 ); + is( tell($fh), 3, 'tell before rename' ); + + rename( '/fake/t_old.txt', '/fake/t_new.txt' ) or die "rename: $!"; + + is( tell($fh), 3, 'tell unchanged after rename' ); + read( $fh, $buf, 3 ); + is( $buf, 'DEF', 'continue reading from correct position' ); + + close $fh; +}; + +subtest 'multiple handles survive rename' => sub { + my $old = Test::MockFile->file( '/fake/m_old.txt', 'DATA' ); + my $new = Test::MockFile->file( '/fake/m_new.txt', '' ); + + open( my $fh1, '<', '/fake/m_old.txt' ) or die "open1: $!"; + open( my $fh2, '<', '/fake/m_old.txt' ) or die "open2: $!"; + + # Advance fh1 but not fh2 + my $buf; + read( $fh1, $buf, 2 ); + + rename( '/fake/m_old.txt', '/fake/m_new.txt' ) or die "rename: $!"; + + read( $fh1, $buf, 2 ); + is( $buf, 'TA', 'handle 1 continues from tell=2' ); + + read( $fh2, $buf, 4 ); + is( $buf, 'DATA', 'handle 2 reads full contents from tell=0' ); + + close $fh1; + close $fh2; +}; + +subtest 'eof works after rename' => sub { + my $old = Test::MockFile->file( '/fake/e_old.txt', 'AB' ); + my $new = Test::MockFile->file( '/fake/e_new.txt', '' ); + + open( my $fh, '<', '/fake/e_old.txt' ) or die "open: $!"; + my $buf; + read( $fh, $buf, 2 ); + + rename( '/fake/e_old.txt', '/fake/e_new.txt' ) or die "rename: $!"; + + ok( eof($fh), 'eof returns true after rename when at end' ); + + close $fh; +}; + +done_testing();