Skip to content

[Console] Issue in multiple ProgressIndicator #62306

@LucianMihalache

Description

@LucianMihalache

Symfony version(s) affected

At least all versions of in the past 3 years based on git blame

Description

This is going to be a bit of a long one, as it contains the explanation and a possible fix.

I've been playing around with a console script.
I am triggering subprocesses to run them in parallel. I wanted to make the console look nice, for no particular reason, to show a loading like a throbber as the subprocesses take some time.

So I stumbled upon Symfony\Component\Console\Helper\ProgressIndicator.
Great! Or so I thought... All nice and good until, for some reason that I could not manage to debug, after the exact 5th redraw of the indicator, it starts eating up the previous text (the lines above the section).
Each iteration, the indicator gets pushed up in the terminal.

After hours of debugging to understand if it's a me-issue, I managed to track it down to this function inside ProgressIndicator class:

    private function overwrite(string $message): void
    {
        if ($this->output->isDecorated()) {
            $this->output->write("\x0D\x1B[2K");
            $this->output->write($message);
        } else {
            $this->output->writeln($message);
        }
    }

$this->output is an instance of the ConsoleSectionOutput class.
write function is all the way in Symfony\Component\Console\Output\Output.
Internally, it calls doWrite.
doWrite is overwritten in ConsoleSectionOutput.

The doWrite are doing some newline stuff. Which is fine I guess.
But at the same time, the ProgressIndicator's internal overwrite does ->write("\x0D\x1B[2K").
That thing over there tells the terminal to move the cursor at the end of the line, and then to delete everything on that line, or so I understand.

There seems to be a weird conflict between this and the doWrite's newLines, which results in this weird behavior of the sections moving up in terminal if you have multiple of them.

How to reproduce

Here is a small script that takes an array of Processes (Symfony\Component\Process\Process), starts them, and shows a throbber while waiting for the processes to finish.

protected function runAndWatchProcesses(array $processes): void
{
    /** @var ConsoleOutputInterface $console */
    $console = $this->output->getOutput();
    $sections = [];
    $indicators  = [];
    foreach ($processes as $pid => $p) {
        if (!$p->isRunning()) {
            $p->start();
        }

        $section = $console->section();
        $sections[$pid] = $section;
        $indicator = new ProgressIndicator(
            output: $section,
            indicatorValues: ['','','','','','','','','','']
        );
        $indicator->start('⚙️ Starting async process...');
        $indicators[$pid] = $indicator;
    }

    while (!empty($processes)) {
        foreach ($processes as $pid => $p) {
            $lastLineOfText = $this->lastNonEmptyLine($p->getIncrementalErrorOutput() ?: $p->getIncrementalOutput());
            if ($lastLineOfText) {
                $indicators[$pid]->setMessage($lastLineOfText);
            }

            $indicators[$pid]->advance();
            if (!$p->isRunning()) {
                $indicators[$pid]->finish($lastLineOfText, $p->isSuccessful() ? '' : ''); // Because finish does this weird and it always shows Ok even if the process failed
                unset($processes[$pid], $indicators[$pid], $sections[$pid]);
            }
        }
        usleep(100_000);
    }
}

Example of the processes array:
It will require adaptations, of course...

$projects = [1,2,3,4,5];
$processes = [];
foreach ($projects as $projectId) {
    $process = new Process([
        PHP_BINARY,
        'artisan',
        'time-consuming: command',
        "--project=$projectId",
    ]);
    $process->setTimeout(900);
    $processes[$projectId] = $process;
}

Possible Solution

The fix is to just use the "native" overwrite of the output (ConsoleSectionOutput) without using the \x0D\x1B[2K trick. For some reason that fixed it.

    private function overwrite(string $message): void
    {
        if ($this->output->isDecorated()) {
            //$this->output->write("\x0D\x1B[2K");
            $this->output->overwrite($message);
        } else {
            $this->output->writeln($message);
        }
    }

Additional Context

Gifs to explain:

Image

Image

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions