From 997de0a2c0a133c4ef09eb4bf707867215919d17 Mon Sep 17 00:00:00 2001 From: Pascal CESCON - Amoifr Date: Mon, 11 May 2026 11:03:33 +0200 Subject: [PATCH 1/2] [Console] Make `ConsoleSectionOutput::overwrite()` atomic --- Output/ConsoleSectionOutput.php | 27 +++++++++++++++++++++-- Tests/Output/ConsoleSectionOutputTest.php | 16 +++++++++++++- 2 files changed, 40 insertions(+), 3 deletions(-) diff --git a/Output/ConsoleSectionOutput.php b/Output/ConsoleSectionOutput.php index 04d587cf2..6e06f2839 100644 --- a/Output/ConsoleSectionOutput.php +++ b/Output/ConsoleSectionOutput.php @@ -88,8 +88,31 @@ public function clear(?int $lines = null) */ public function overwrite(string|iterable $message) { - $this->clear(); - $this->writeln($message); + if (!$this->content || !$this->isDecorated()) { + $this->writeln($message); + + return; + } + + // Replace own content and write everything in a single cursor-up + erase + // pass, to avoid the flicker (and the line-eating artifacts on some + // terminals) caused by calling clear() then writeln() back-to-back. + $linesCleared = $this->lines; + $this->content = []; + $this->lines = 0; + + if (!is_iterable($message)) { + $message = [$message]; + } + + foreach ($message as $line) { + $this->addContent($line, true); + } + + $erasedContent = $this->popStreamContentUntilCurrentSection($this->maxHeight ? min($this->maxHeight, $linesCleared) : $linesCleared); + + parent::doWrite($this->getVisibleContent(), false); + parent::doWrite($erasedContent, false); } public function getContent(): string diff --git a/Tests/Output/ConsoleSectionOutputTest.php b/Tests/Output/ConsoleSectionOutputTest.php index 6d2fa6c3c..7dc70c85f 100644 --- a/Tests/Output/ConsoleSectionOutputTest.php +++ b/Tests/Output/ConsoleSectionOutputTest.php @@ -200,6 +200,20 @@ public function testOverwriteMultipleLines() $this->assertEquals('Foo'.\PHP_EOL.'Bar'.\PHP_EOL.'Baz'.\PHP_EOL.\sprintf("\x1b[%dA", 3)."\x1b[0J".'Bar'.\PHP_EOL, stream_get_contents($output->getStream())); } + public function testOverwriteWithMaxHeightOverflow() + { + $sections = []; + $output = new ConsoleSectionOutput($this->stream, $sections, OutputInterface::VERBOSITY_NORMAL, true, new OutputFormatter()); + $output->setMaxHeight(2); + + $output->writeln(['One', 'Two']); + // overwrite with more lines than the max height: only the last lines stay visible + $output->overwrite('A'.\PHP_EOL.'B'.\PHP_EOL.'C'); + + rewind($output->getStream()); + $this->assertEquals('One'.\PHP_EOL.'Two'.\PHP_EOL."\x1b[2A\x1b[0J".'B'.\PHP_EOL.'C'.\PHP_EOL, stream_get_contents($output->getStream())); + } + public function testAddingMultipleSections() { $sections = []; @@ -223,7 +237,7 @@ public function testMultipleSectionsOutput() $output2->overwrite('Foobar'); rewind($output->getStream()); - $this->assertEquals('Foo'.\PHP_EOL.'Bar'.\PHP_EOL."\x1b[2A\x1b[0JBar".\PHP_EOL."\x1b[1A\x1b[0JBaz".\PHP_EOL.'Bar'.\PHP_EOL."\x1b[1A\x1b[0JFoobar".\PHP_EOL, stream_get_contents($output->getStream())); + $this->assertEquals('Foo'.\PHP_EOL.'Bar'.\PHP_EOL."\x1b[2A\x1b[0JBaz".\PHP_EOL.'Bar'.\PHP_EOL."\x1b[1A\x1b[0JFoobar".\PHP_EOL, stream_get_contents($output->getStream())); } public function testMultipleSectionsOutputWithoutNewline() From c132f1215fe4aa45b70173cc00ce9a755dd31ec5 Mon Sep 17 00:00:00 2001 From: Jeremy Pollard Date: Mon, 11 May 2026 17:45:33 -0700 Subject: [PATCH 2/2] [Console] Fix signal handler scoping --- SignalRegistry/SignalRegistry.php | 25 +++++++- Tests/SignalRegistry/SignalRegistryTest.php | 70 +++++++++++++++++++++ 2 files changed, 92 insertions(+), 3 deletions(-) diff --git a/SignalRegistry/SignalRegistry.php b/SignalRegistry/SignalRegistry.php index ac8851b06..f826dbc82 100644 --- a/SignalRegistry/SignalRegistry.php +++ b/SignalRegistry/SignalRegistry.php @@ -81,6 +81,14 @@ public function handle(int $signal): void */ public function pushCurrentHandlers(): void { + // Restore the original OS-level disposition while the active map is empty, + // so signals are not routed through handle() without a registered handler. + foreach ($this->signalHandlers as $signal => $handlers) { + if (isset($this->originalHandlers[$signal])) { + pcntl_signal($signal, $this->originalHandlers[$signal]); + } + } + $this->stack[] = $this->signalHandlers; $this->signalHandlers = []; } @@ -96,13 +104,24 @@ public function pushCurrentHandlers(): void public function popPreviousHandlers(): void { $popped = $this->signalHandlers; - $this->signalHandlers = array_pop($this->stack) ?? []; + $previous = array_pop($this->stack) ?? []; - // Restore OS handler if no more Symfony handlers for this signal + // Expose a transitional superset so handle() never reads a missing key + // if a signal lands while the OS-level handlers below are being swapped. + $this->signalHandlers = $previous + $popped; + + // Reinstall the registry handler for signals owned by the restored scope. + foreach ($previous as $signal => $handlers) { + pcntl_signal($signal, [$this, 'handle']); + } + + // Restore the original OS-level handler for signals no scope owns anymore. foreach ($popped as $signal => $handlers) { - if (!($this->signalHandlers[$signal] ?? false) && isset($this->originalHandlers[$signal])) { + if (!isset($previous[$signal]) && isset($this->originalHandlers[$signal])) { pcntl_signal($signal, $this->originalHandlers[$signal]); } } + + $this->signalHandlers = $previous; } } diff --git a/Tests/SignalRegistry/SignalRegistryTest.php b/Tests/SignalRegistry/SignalRegistryTest.php index 0ab529c80..a21525b21 100644 --- a/Tests/SignalRegistry/SignalRegistryTest.php +++ b/Tests/SignalRegistry/SignalRegistryTest.php @@ -160,6 +160,76 @@ public function testPushPopIsolatesHandlers() $this->assertCount(0, $this->getHandlersForSignal($registry, $signal)); } + public function testPushCurrentHandlersRestoresOriginalHandler() + { + $registry = new SignalRegistry(); + + $previousHandlerCalled = false; + pcntl_signal(\SIGUSR1, static function () use (&$previousHandlerCalled) { + $previousHandlerCalled = true; + }); + + $registeredHandlerCalled = false; + $registry->register(\SIGUSR1, static function () use (&$registeredHandlerCalled) { + $registeredHandlerCalled = true; + }); + + $registry->pushCurrentHandlers(); + + posix_kill(posix_getpid(), \SIGUSR1); + + $this->assertTrue($previousHandlerCalled); + $this->assertFalse($registeredHandlerCalled); + } + + public function testPopPreviousHandlersRestoresPreviousOsHandler() + { + $registry = new SignalRegistry(); + + $outerHandlerCalled = false; + $registry->register(\SIGUSR1, static function () use (&$outerHandlerCalled) { + $outerHandlerCalled = true; + }); + + $registry->pushCurrentHandlers(); + + $innerHandlerCalled = false; + $registry->register(\SIGUSR2, static function () use (&$innerHandlerCalled) { + $innerHandlerCalled = true; + }); + + $registry->popPreviousHandlers(); + + posix_kill(posix_getpid(), \SIGUSR1); + + $this->assertTrue($outerHandlerCalled); + $this->assertFalse($innerHandlerCalled); + } + + public function testPopPreviousHandlersRestoresPreviousOsHandlerForSameSignal() + { + $registry = new SignalRegistry(); + + $outerHandlerCalled = false; + $registry->register(\SIGUSR1, static function () use (&$outerHandlerCalled) { + $outerHandlerCalled = true; + }); + + $registry->pushCurrentHandlers(); + + $innerHandlerCalled = false; + $registry->register(\SIGUSR1, static function () use (&$innerHandlerCalled) { + $innerHandlerCalled = true; + }); + + $registry->popPreviousHandlers(); + + posix_kill(posix_getpid(), \SIGUSR1); + + $this->assertTrue($outerHandlerCalled); + $this->assertFalse($innerHandlerCalled); + } + public function testRestoreOriginalOnEmptyAfterPop() { if (!\extension_loaded('pcntl')) {