ILIAS  trunk Revision v13.0_alpha-62-g3b72d83f2e5
MigratedLanguageFileSyncLockRetryTest.php
Go to the documentation of this file.
1<?php
2
19declare(strict_types=1);
20
25
29class MigratedLanguageFileSyncLockRetryTest extends MigratedLanguageFileSyncLockTestCase
30{
38 private function waitForOpenFd(int $pid, string $path, float $timeout_seconds, array $ignored_fds = []): bool
39 {
40 $deadline = microtime(true) + $timeout_seconds;
41 do {
42 foreach ($this->openFdsFor($pid, $path) as $entry) {
43 if (!in_array($entry, $ignored_fds, true)) {
44 return true;
45 }
46 }
47 usleep(1_000);
48 } while (microtime(true) < $deadline);
49
50 return false;
51 }
52
57 private function requireProcFdSupport(): void
58 {
59 if (!is_dir('/proc/self/fd')) {
60 $this->markTestSkipped('This test requires /proc (Linux) to observe the child process\'s open file descriptors.');
61 }
62 }
63
72 private function openFdsFor(int $pid, string $path): array
73 {
74 $matches = [];
75 $entries = @scandir('/proc/' . $pid . '/fd');
76 if ($entries === false) {
77 return $matches;
78 }
79 $resolved_path = realpath($path) ?: $path;
80 foreach ($entries as $entry) {
81 if ($entry === '.' || $entry === '..') {
82 continue;
83 }
84 if (@readlink('/proc/' . $pid . '/fd/' . $entry) === $resolved_path) {
85 $matches[] = $entry;
86 }
87 }
88
89 return $matches;
90 }
91
98 private function seedIgnoredFds(int $pid, string $path): array
99 {
100 return $this->openFdsFor($pid, $path);
101 }
102
103 private function lockAttempts(): int
104 {
105 return (new \ReflectionClassConstant(MigratedLanguageFileSync::class, 'LOCK_ATTEMPTS'))->getValue();
106 }
107
113 private function acquireOnceChildCode(?string $error_log_file = null): string
114 {
115 $template = <<<'PHP'
120
121 $root = __ROOT__;
122 require_once $root . '/vendor/composer/vendor/autoload.php';
123
124 $error_log_file = __ERROR_LOG_FILE__;
125 if ($error_log_file !== null) {
126 ini_set('error_log', $error_log_file);
127 }
128
129 $directory = new class implements LanguageFileDirectory {
130 public function getPrefix(): string
131 {
132 return 'stest';
133 }
134 public function getPath(): string
135 {
136 return 'x/';
137 }
138 public function getSuffix(): string
139 {
140 return '';
141 }
142 public function isLocal(): bool
143 {
144 return false;
145 }
146 };
147 $manager = new LanguageFileDirectoryManager(new CustomizingLanguageFileDirectory(), $directory);
148 $client_data_dir = __CLIENT_DATA_DIR__;
149
150 try {
151 $calls = 0;
152 $result = MigratedLanguageFileSync::withOverlayLock(
153 $manager,
154 'de',
155 'stest',
156 $client_data_dir,
157 static function () use (&$calls, $client_data_dir): string {
158 $calls++;
159 // the inode of the lock file held right now - the lock file itself is
160 // removed afterwards (no overlay exists here)
161 clearstatcache();
162 $held = @lstat($client_data_dir . '/lang/stest/de/lock');
163 echo 'HELD:' . ($held === false ? '' : $held['ino']) . "\n";
164
165 return 'ok';
166 }
167 );
168 echo 'RESULT:' . $result . "\n";
169 echo 'CALLS:' . $calls . "\n";
170 } catch (\Throwable $e) {
171 echo 'EXCEPTION:' . get_class($e) . ':' . $e->getMessage() . "\n";
172 }
173 PHP;
174
175 return str_replace(
176 ['__ROOT__', '__CLIENT_DATA_DIR__', '__ERROR_LOG_FILE__'],
177 [
178 var_export(ILIAS_ABSOLUTE_PATH, true),
179 var_export($this->client_data_dir, true),
180 var_export($error_log_file, true),
181 ],
182 $template
183 );
184 }
185
189 private function startAcquireOnceChild(?string $error_log_file = null): array
190 {
191 $started = ChildPhp::open(['-r', $this->acquireOnceChildCode($error_log_file)]);
192 if ($started === null) {
193 $this->fail('Could not start the child PHP process.');
194 }
195 [$process, $pipes] = $started;
196 stream_set_blocking($pipes[1], false);
197 stream_set_blocking($pipes[2], false);
198 $status = proc_get_status($process);
199
200 return ['pid' => $status['pid'], 'process' => $process, 'pipes' => $pipes];
201 }
202
208 private function drainChild($process, array $pipes, float $timeout_seconds): array
209 {
210 $deadline = microtime(true) + $timeout_seconds;
211 $output = '';
212 do {
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']) {
217 fclose($pipes[1]);
218 fclose($pipes[2]);
219 proc_close($process);
220
221 return ['finished' => true, 'output' => $output];
222 }
223 usleep(20_000);
224 } while (microtime(true) < $deadline);
225
226 proc_terminate($process, 9);
227 usleep(100_000);
228 $output .= (string) stream_get_contents($pipes[1]);
229 $output .= (string) stream_get_contents($pipes[2]);
230 fclose($pipes[1]);
231 fclose($pipes[2]);
232 proc_close($process);
233
234 return ['finished' => false, 'output' => $output];
235 }
236
243 private function terminateChildIfRunning(array $child): void
244 {
245 if (is_resource($child['process'])) {
246 $status = proc_get_status($child['process']);
247 if ($status['running']) {
248 proc_terminate($child['process'], 9);
249 usleep(100_000);
250 }
251 }
252 foreach ($child['pipes'] as $pipe) {
253 if (is_resource($pipe)) {
254 @fclose($pipe);
255 }
256 }
257 if (is_resource($child['process'])) {
258 @proc_close($child['process']);
259 }
260 }
261
269 private function replaceLockedFile(string $lock_file, $current_handle)
270 {
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);
277
278 return $next_handle;
279 }
280
285 public function testAWaiterThatLocksAnInodeDeletedInTheMeantimeRetriesAndEndsUpHoldingTheNewFile(): void
286 {
287 $this->requireProcFdSupport();
288 $this->seedLockDirectory();
289 $lock_file = $this->lockFile();
290
291 $current_handle = fopen($lock_file, 'c');
292 $this->assertNotFalse($current_handle);
293 $this->assertTrue(flock($current_handle, LOCK_EX));
294
295 $child = $this->startAcquireOnceChild();
296 $ignored_fds = $this->seedIgnoredFds($child['pid'], $lock_file);
297 try {
298 for ($round = 1; $round <= 2; $round++) {
299 $this->assertTrue(
300 $this->waitForOpenFd($child['pid'], $lock_file, 5.0, $ignored_fds),
301 'the child never opened the lock file for attempt ' . $round
302 );
303 $current_handle = $this->replaceLockedFile($lock_file, $current_handle);
304 }
305
306 $this->assertTrue(
307 $this->waitForOpenFd($child['pid'], $lock_file, 5.0, $ignored_fds),
308 'the child never opened the lock file for the final, stable attempt'
309 );
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;
315
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']);
319
320 $this->assertStringContainsString('HELD:' . $final_inode . "\n", $result['output']);
321 clearstatcache(true, $lock_file);
322 $this->assertSame(
323 $final_inode,
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'
326 );
327 } finally {
328 if (is_resource($current_handle)) {
329 flock($current_handle, LOCK_UN);
330 fclose($current_handle);
331 }
332 $this->terminateChildIfRunning($child);
333 }
334 }
335
340 public function testTheLockFileReplacedOnEveryAttemptExhaustsTheRetriesAndRunsCallbackOnceUnlockedWithAWarningLogged(): void
341 {
342 $this->requireProcFdSupport();
343 $this->seedLockDirectory();
344 $lock_file = $this->lockFile();
345 $attempts = $this->lockAttempts();
346 $error_log_file = $this->client_data_dir . '-error-log-exhausted.txt';
347
348 $current_handle = fopen($lock_file, 'c');
349 $this->assertNotFalse($current_handle);
350 $this->assertTrue(flock($current_handle, LOCK_EX));
351
352 $child = $this->startAcquireOnceChild($error_log_file);
353 $ignored_fds = $this->seedIgnoredFds($child['pid'], $lock_file);
354 try {
355 for ($round = 1; $round <= $attempts; $round++) {
356 $this->assertTrue(
357 $this->waitForOpenFd($child['pid'], $lock_file, 5.0, $ignored_fds),
358 'the child never opened the lock file for attempt ' . $round
359 );
360 $current_handle = $this->replaceLockedFile($lock_file, $current_handle);
361 }
362 flock($current_handle, LOCK_UN);
363 fclose($current_handle);
364 $current_handle = null;
365
366 $result = $this->drainChild($child['process'], $child['pipes'], 5.0);
367
368 $this->assertTrue($result['finished'], 'the child must not hang: ' . $result['output']);
369 $this->assertStringContainsString(
370 'RESULT:ok',
371 $result['output'],
372 'exhausting LOCK_ATTEMPTS must fall back to running the callback unlocked, not throw'
373 );
374 $this->assertStringContainsString('CALLS:1', $result['output'], 'the callback must run exactly once');
375 $this->assertStringNotContainsString('EXCEPTION', $result['output']);
376
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);
381 } finally {
382 if (is_resource($current_handle)) {
383 flock($current_handle, LOCK_UN);
384 fclose($current_handle);
385 }
386 $this->terminateChildIfRunning($child);
387 @unlink($error_log_file);
388 }
389 }
390
395 public function testTheLockFileReplacedOnlyOnTheFirstAttemptStillAcquiresTheLockNormallyWithoutAWarning(): void
396 {
397 $this->requireProcFdSupport();
398 $this->seedLockDirectory();
399 $lock_file = $this->lockFile();
400 $error_log_file = $this->client_data_dir . '-error-log-recovered.txt';
401
402 $current_handle = fopen($lock_file, 'c');
403 $this->assertNotFalse($current_handle);
404 $this->assertTrue(flock($current_handle, LOCK_EX));
405
406 $child = $this->startAcquireOnceChild($error_log_file);
407 $ignored_fds = $this->seedIgnoredFds($child['pid'], $lock_file);
408 try {
409 $this->assertTrue(
410 $this->waitForOpenFd($child['pid'], $lock_file, 5.0, $ignored_fds),
411 'the child never opened the lock file for the first attempt'
412 );
413 $current_handle = $this->replaceLockedFile($lock_file, $current_handle);
414
415 $this->assertTrue(
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'
418 );
419 flock($current_handle, LOCK_UN);
420 fclose($current_handle);
421 $current_handle = null;
422
423 $result = $this->drainChild($child['process'], $child['pipes'], 5.0);
424
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']);
429
430 $this->assertSame(
431 '',
432 (string) @file_get_contents($error_log_file),
433 'recovering on a later attempt must not log the "removed or replaced" warning'
434 );
435 } finally {
436 if (is_resource($current_handle)) {
437 flock($current_handle, LOCK_UN);
438 fclose($current_handle);
439 }
440 $this->terminateChildIfRunning($child);
441 @unlink($error_log_file);
442 }
443 }
444
449 public function testTheSymbolicLinkCheckAppliesOnEveryAttemptNotJustTheFirst(): void
450 {
451 $this->requireProcFdSupport();
452 $this->seedLockDirectory();
453 $lock_file = $this->lockFile();
454 $decoy = $this->client_data_dir . '-decoy-lock-target';
455 file_put_contents($decoy, 'DECOY');
456
457 $current_handle = fopen($lock_file, 'c');
458 $this->assertNotFalse($current_handle);
459 $this->assertTrue(flock($current_handle, LOCK_EX));
460
461 $child = $this->startAcquireOnceChild();
462 $ignored_fds = $this->seedIgnoredFds($child['pid'], $lock_file);
463 try {
464 $this->assertTrue(
465 $this->waitForOpenFd($child['pid'], $lock_file, 5.0, $ignored_fds),
466 'the child never opened the lock file for the first attempt'
467 );
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;
473
474 $result = $this->drainChild($child['process'], $child['pipes'], 5.0);
475
476 $this->assertTrue($result['finished'], 'the child must not hang: ' . $result['output']);
477 $this->assertStringContainsString(
478 'RESULT:ok',
479 $result['output'],
480 'a symlink discovered on a LATER attempt must fall back to running unlocked, not bubble up an exception'
481 );
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');
484 } finally {
485 @unlink($decoy);
486 if (is_resource($current_handle)) {
487 flock($current_handle, LOCK_UN);
488 fclose($current_handle);
489 }
490 $this->terminateChildIfRunning($child);
491 }
492 }
493}
static open(array $arguments, ?string $working_directory=null)
Definition: ChildPhp.php:43
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.