19declare(strict_types=1);
38 private function waitForOpenFd(
int $pid,
string $path,
float $timeout_seconds, array $ignored_fds = []): bool
40 $deadline = microtime(
true) + $timeout_seconds;
42 foreach ($this->openFdsFor($pid, $path) as $entry) {
43 if (!in_array($entry, $ignored_fds,
true)) {
48 }
while (microtime(
true) < $deadline);
57 private function requireProcFdSupport(): void
59 if (!is_dir(
'/proc/self/fd')) {
60 $this->markTestSkipped(
'This test requires /proc (Linux) to observe the child process\'s open file descriptors.');
72 private function openFdsFor(
int $pid,
string $path): array
75 $entries = @scandir(
'/proc/' . $pid .
'/fd');
76 if ($entries ===
false) {
79 $resolved_path = realpath($path) ?: $path;
80 foreach ($entries as $entry) {
81 if ($entry ===
'.' || $entry ===
'..') {
84 if (@readlink(
'/proc/' . $pid .
'/fd/' . $entry) === $resolved_path) {
98 private function seedIgnoredFds(
int $pid,
string $path): array
100 return $this->openFdsFor($pid, $path);
103 private function lockAttempts():
int
105 return (
new \ReflectionClassConstant(MigratedLanguageFileSync::class,
'LOCK_ATTEMPTS'))->getValue();
113 private function acquireOnceChildCode(?
string $error_log_file =
null): string
122 require_once $root .
'/vendor/composer/vendor/autoload.php';
124 $error_log_file = __ERROR_LOG_FILE__;
125 if ($error_log_file !==
null) {
126 ini_set(
'error_log', $error_log_file);
130 public function getPrefix(): string
134 public function getPath(): string
138 public function getSuffix(): string
142 public function isLocal(): bool
148 $client_data_dir = __CLIENT_DATA_DIR__;
152 $result = MigratedLanguageFileSync::withOverlayLock(
157 static function () use (&$calls, $client_data_dir):
string {
162 $held = @lstat($client_data_dir .
'/lang/stest/de/lock');
163 echo
'HELD:' . ($held ===
false ?
'' : $held[
'ino']) .
"\n";
168 echo
'RESULT:' . $result .
"\n";
169 echo
'CALLS:' . $calls .
"\n";
170 }
catch (\Throwable
$e) {
171 echo
'EXCEPTION:' . get_class(
$e) .
':' .
$e->getMessage() .
"\n";
176 [
'__ROOT__',
'__CLIENT_DATA_DIR__',
'__ERROR_LOG_FILE__'],
178 var_export(ILIAS_ABSOLUTE_PATH,
true),
179 var_export($this->client_data_dir,
true),
180 var_export($error_log_file,
true),
189 private function startAcquireOnceChild(?
string $error_log_file =
null): array
191 $started =
ChildPhp::open([
'-r', $this->acquireOnceChildCode($error_log_file)]);
192 if ($started ===
null) {
193 $this->fail(
'Could not start the child PHP process.');
195 [$process, $pipes] = $started;
196 stream_set_blocking($pipes[1],
false);
197 stream_set_blocking($pipes[2],
false);
198 $status = proc_get_status($process);
200 return [
'pid' => $status[
'pid'],
'process' => $process,
'pipes' => $pipes];
208 private function drainChild($process, array $pipes,
float $timeout_seconds): array
210 $deadline = microtime(
true) + $timeout_seconds;
213 $output .= (string) stream_get_contents($pipes[1]);
214 $output .= (string) stream_get_contents($pipes[2]);
215 $status = proc_get_status($process);
216 if (!$status[
'running']) {
219 proc_close($process);
221 return [
'finished' =>
true,
'output' => $output];
224 }
while (microtime(
true) < $deadline);
226 proc_terminate($process, 9);
228 $output .= (string) stream_get_contents($pipes[1]);
229 $output .= (string) stream_get_contents($pipes[2]);
232 proc_close($process);
234 return [
'finished' =>
false,
'output' => $output];
243 private function terminateChildIfRunning(array $child): void
245 if (is_resource($child[
'process'])) {
246 $status = proc_get_status($child[
'process']);
247 if ($status[
'running']) {
248 proc_terminate($child[
'process'], 9);
252 foreach ($child[
'pipes'] as $pipe) {
253 if (is_resource($pipe)) {
257 if (is_resource($child[
'process'])) {
258 @proc_close($child[
'process']);
269 private function replaceLockedFile(
string $lock_file, $current_handle)
271 $this->assertTrue((
bool) @unlink($lock_file),
'could not unlink the lock file to replace it');
272 $next_handle = fopen($lock_file,
'c');
273 $this->assertNotFalse($next_handle,
'could not recreate the lock file');
274 $this->assertTrue(flock($next_handle, LOCK_EX));
275 flock($current_handle, LOCK_UN);
276 fclose($current_handle);
285 public function testAWaiterThatLocksAnInodeDeletedInTheMeantimeRetriesAndEndsUpHoldingTheNewFile(): void
287 $this->requireProcFdSupport();
291 $current_handle = fopen($lock_file,
'c');
292 $this->assertNotFalse($current_handle);
293 $this->assertTrue(flock($current_handle, LOCK_EX));
295 $child = $this->startAcquireOnceChild();
296 $ignored_fds = $this->seedIgnoredFds($child[
'pid'], $lock_file);
298 for ($round = 1; $round <= 2; $round++) {
300 $this->waitForOpenFd($child[
'pid'], $lock_file, 5.0, $ignored_fds),
301 'the child never opened the lock file for attempt ' . $round
303 $current_handle = $this->replaceLockedFile($lock_file, $current_handle);
307 $this->waitForOpenFd($child[
'pid'], $lock_file, 5.0, $ignored_fds),
308 'the child never opened the lock file for the final, stable attempt'
310 clearstatcache(
true, $lock_file);
311 $final_inode = lstat($lock_file)[
'ino'];
312 flock($current_handle, LOCK_UN);
313 fclose($current_handle);
314 $current_handle =
null;
316 $result = $this->drainChild($child[
'process'], $child[
'pipes'], 5.0);
317 $this->assertTrue($result[
'finished'],
'the child must not hang: ' . $result[
'output']);
318 $this->assertStringContainsString(
'RESULT:ok', $result[
'output']);
320 $this->assertStringContainsString(
'HELD:' . $final_inode .
"\n", $result[
'output']);
321 clearstatcache(
true, $lock_file);
324 lstat($lock_file)[
'ino'],
325 'the waiter must end up holding the file that was actually left in place, not trigger a further replacement'
328 if (is_resource($current_handle)) {
329 flock($current_handle, LOCK_UN);
330 fclose($current_handle);
332 $this->terminateChildIfRunning($child);
340 public function testTheLockFileReplacedOnEveryAttemptExhaustsTheRetriesAndRunsCallbackOnceUnlockedWithAWarningLogged(): void
342 $this->requireProcFdSupport();
345 $attempts = $this->lockAttempts();
346 $error_log_file = $this->client_data_dir .
'-error-log-exhausted.txt';
348 $current_handle = fopen($lock_file,
'c');
349 $this->assertNotFalse($current_handle);
350 $this->assertTrue(flock($current_handle, LOCK_EX));
352 $child = $this->startAcquireOnceChild($error_log_file);
353 $ignored_fds = $this->seedIgnoredFds($child[
'pid'], $lock_file);
355 for ($round = 1; $round <= $attempts; $round++) {
357 $this->waitForOpenFd($child[
'pid'], $lock_file, 5.0, $ignored_fds),
358 'the child never opened the lock file for attempt ' . $round
360 $current_handle = $this->replaceLockedFile($lock_file, $current_handle);
362 flock($current_handle, LOCK_UN);
363 fclose($current_handle);
364 $current_handle =
null;
366 $result = $this->drainChild($child[
'process'], $child[
'pipes'], 5.0);
368 $this->assertTrue($result[
'finished'],
'the child must not hang: ' . $result[
'output']);
369 $this->assertStringContainsString(
372 'exhausting LOCK_ATTEMPTS must fall back to running the callback unlocked, not throw'
374 $this->assertStringContainsString(
'CALLS:1', $result[
'output'],
'the callback must run exactly once');
375 $this->assertStringNotContainsString(
'EXCEPTION', $result[
'output']);
377 $this->assertFileExists($error_log_file,
'exhausting LOCK_ATTEMPTS must log a warning');
378 $warning = (string) file_get_contents($error_log_file);
379 $this->assertStringContainsString(
'removed or replaced', $warning);
380 $this->assertStringContainsString((
string) $attempts, $warning);
382 if (is_resource($current_handle)) {
383 flock($current_handle, LOCK_UN);
384 fclose($current_handle);
386 $this->terminateChildIfRunning($child);
387 @unlink($error_log_file);
395 public function testTheLockFileReplacedOnlyOnTheFirstAttemptStillAcquiresTheLockNormallyWithoutAWarning(): void
397 $this->requireProcFdSupport();
400 $error_log_file = $this->client_data_dir .
'-error-log-recovered.txt';
402 $current_handle = fopen($lock_file,
'c');
403 $this->assertNotFalse($current_handle);
404 $this->assertTrue(flock($current_handle, LOCK_EX));
406 $child = $this->startAcquireOnceChild($error_log_file);
407 $ignored_fds = $this->seedIgnoredFds($child[
'pid'], $lock_file);
410 $this->waitForOpenFd($child[
'pid'], $lock_file, 5.0, $ignored_fds),
411 'the child never opened the lock file for the first attempt'
413 $current_handle = $this->replaceLockedFile($lock_file, $current_handle);
416 $this->waitForOpenFd($child[
'pid'], $lock_file, 5.0, $ignored_fds),
417 'the child never opened the lock file for the second, now-stable attempt'
419 flock($current_handle, LOCK_UN);
420 fclose($current_handle);
421 $current_handle =
null;
423 $result = $this->drainChild($child[
'process'], $child[
'pipes'], 5.0);
425 $this->assertTrue($result[
'finished'],
'the child must not hang: ' . $result[
'output']);
426 $this->assertStringContainsString(
'RESULT:ok', $result[
'output']);
427 $this->assertStringContainsString(
'CALLS:1', $result[
'output'],
'the callback must run exactly once');
428 $this->assertStringNotContainsString(
'EXCEPTION', $result[
'output']);
432 (
string) @file_get_contents($error_log_file),
433 'recovering on a later attempt must not log the "removed or replaced" warning'
436 if (is_resource($current_handle)) {
437 flock($current_handle, LOCK_UN);
438 fclose($current_handle);
440 $this->terminateChildIfRunning($child);
441 @unlink($error_log_file);
449 public function testTheSymbolicLinkCheckAppliesOnEveryAttemptNotJustTheFirst(): void
451 $this->requireProcFdSupport();
454 $decoy = $this->client_data_dir .
'-decoy-lock-target';
455 file_put_contents($decoy,
'DECOY');
457 $current_handle = fopen($lock_file,
'c');
458 $this->assertNotFalse($current_handle);
459 $this->assertTrue(flock($current_handle, LOCK_EX));
461 $child = $this->startAcquireOnceChild();
462 $ignored_fds = $this->seedIgnoredFds($child[
'pid'], $lock_file);
465 $this->waitForOpenFd($child[
'pid'], $lock_file, 5.0, $ignored_fds),
466 'the child never opened the lock file for the first attempt'
468 $this->assertTrue((
bool) @unlink($lock_file));
469 symlink($decoy, $lock_file);
470 flock($current_handle, LOCK_UN);
471 fclose($current_handle);
472 $current_handle =
null;
474 $result = $this->drainChild($child[
'process'], $child[
'pipes'], 5.0);
476 $this->assertTrue($result[
'finished'],
'the child must not hang: ' . $result[
'output']);
477 $this->assertStringContainsString(
480 'a symlink discovered on a LATER attempt must fall back to running unlocked, not bubble up an exception'
482 $this->assertSame(
'DECOY', file_get_contents($decoy),
'the symlink target must never be touched');
483 $this->assertSame($decoy, readlink($lock_file),
'the symlink itself must be left in place');
486 if (is_resource($current_handle)) {
487 flock($current_handle, LOCK_UN);
488 fclose($current_handle);
490 $this->terminateChildIfRunning($child);
static open(array $arguments, ?string $working_directory=null)
Reads the shipped .po of a migrated module and maintains its per-installation overlay: the ....
Fixture of the MigratedLanguageFileSyncLock*Test classes: a throwaway client data directory and the h...
seedLockDirectory()
Forces withOverlayLock() to take a real lock without needing a shipped .po: the overlay directory alr...
A directory of language files with a path and a prefix for them.