从多个来源捕获彩色控制台输出

Capture coloured console output from multiple sources

我编写了一个控制台应用程序,能够在命令行上并行执行多个命令。
我这样做主要是出于兴趣,因为我正在从事的软件项目的构建过程过度使用了命令行。

目前,在工作线程中创建子进程之前,我创建了一个匿名管道以捕获子进程在其生命周期内创建的所有输出。
子进程终止后,工作线程将捕获的内容推送到等待的主进程,然后打印出来。

这是我的过程创建和捕获:

    procedure ReadPipe(const ReadHandle: THandle; const Output: TStream);
    var
      Buffer: TMemoryStream;
      BytesRead, BytesToRead: DWord;
    begin
      Buffer := TMemoryStream.Create;
      try
        BytesRead := 0;
        BytesToRead := 0;

        if PeekNamedPipe(ReadHandle, nil, 0, nil, @BytesToRead, nil) then
        begin
          if BytesToRead > 0 then
          begin
            Buffer.Size := BytesToRead;
            ReadFile(ReadHandle, Buffer.Memory^, Buffer.Size, BytesRead, nil);

            if Buffer.Size <> BytesRead then
            begin
              Buffer.Size := BytesRead;
            end;

            if Buffer.Size > 0 then
            begin
              Output.Size := Output.Size + Buffer.Size;
              Output.WriteBuffer(Buffer.Memory^, Buffer.Size);
            end;
          end;
        end;
      finally
        Buffer.Free;
      end;
    end;

    function CreateProcessWithRedirectedOutput(const AppName, CMD, DefaultDir: PChar; out CapturedOutput: String): Cardinal;
    const
      TIMEOUT_UNTIL_NEXT_PIPEREAD = 100;
    var
      SecurityAttributes: TSecurityAttributes;
      ReadHandle, WriteHandle: THandle;
      StartupInfo: TStartupInfo;
      ProcessInformation: TProcessInformation;
      ProcessStatus: Cardinal;
      Output: TStringStream;
    begin
      Result := 0;
      CapturedOutput := '';
      Output := TStringStream.Create;
      try
        SecurityAttributes.nLength := SizeOf(SecurityAttributes);
        SecurityAttributes.lpSecurityDescriptor := nil;
        SecurityAttributes.bInheritHandle := True;

        if CreatePipe(ReadHandle, WriteHandle, @SecurityAttributes, 0) then
        begin
          try
            FillChar(StartupInfo, Sizeof(StartupInfo), 0);
            StartupInfo.cb := SizeOf(StartupInfo);
            StartupInfo.hStdOutput := WriteHandle;
            StartupInfo.hStdError := WriteHandle;
            StartupInfo.hStdInput := GetStdHandle(STD_INPUT_HANDLE);
            StartupInfo.dwFlags := STARTF_USESTDHANDLES;

            if CreateProcess(AppName, CMD,
                             @SecurityAttributes, @SecurityAttributes,
                             True, NORMAL_PRIORITY_CLASS,
                             nil, DefaultDir,
                             StartupInfo, ProcessInformation)
            then
            begin

              try
                repeat
                  ProcessStatus := WaitForSingleObject(ProcessInformation.hProcess, TIMEOUT_UNTIL_NEXT_PIPEREAD);
                  ReadPipe(ReadHandle, Output);
                until ProcessStatus <> WAIT_TIMEOUT;

                if not Windows.GetExitCodeProcess(ProcessInformation.hProcess, Result) then
                begin
                  Result := GetLastError;
                end;

              finally
                Windows.CloseHandle(ProcessInformation.hProcess);
                Windows.CloseHandle(ProcessInformation.hThread);
              end;
            end
            else
            begin
              Result := GetLastError;
            end;

          finally
            Windows.CloseHandle(ReadHandle);
            Windows.CloseHandle(WriteHandle);
          end;
        end
        else
        begin
          Result := GetLastError;
        end;

        CapturedOutput := Output.DataString;
      finally
        Output.Free;
      end;
    end;

我现在的问题:
此方法不保留捕获输出的潜在着色!

我偶然发现了这个话题 Capture coloured console output into WPF application 但这对我没有帮助,因为我没有通过匿名管道收到任何颜色数据,只是普通的旧文本。

我尝试通过 CreateFile with 'CONOUT$' 将主进程的控制台继承给子进程,但是虽然颜色确实保留了下来,但您可能会猜到,如果多个进程打印出内容到同一个控制台。

我的下一个方法是使用 CreateConsoleScreenBuffer for each child process and read the contents with ReadConsole 创建额外的控制台缓冲区,但这并不成功,因为 ReadConsole returns 出现系统错误 6 (ERROR_INVALID_HANDLE)。

    ConsoleHandle := CreateConsoleScreenBuffer(
       GENERIC_READ or GENERIC_WRITE,
       FILE_SHARE_READ or FILE_SHARE_WRITE,
       @SecurityAttributes,
       CONSOLE_TEXTMODE_BUFFER,
       nil);
    //...    
    StartupInfo.hStdOutput := ConsoleHandle;
    StartupInfo.hStdError := ConsoleHandle;
    //...
    ConsoleOutput := TMemoryStream.Create
    ConsoleOutput.Size := MAXWORD;
    ConsoleOutput.Position := 0;
    ReadConsole(ConsoleHandle, ConsoleOutput.Memory, ConsoleOutput.Size, CharsRead, nil) // Doesn't read anything and returns with System Error Code 6.

我也阅读了 virtual terminal sequences and AllocConsole, AttachConsole and FreeConsole,但对于我的用例我不能完全理解它。

right/best 子进程控制台输出的着色信息的preserve/receive 方法是什么?

我在 CreateConsoleScreenBuffer 上走在了正确的轨道上,并为每个线程提供了自己的控制台屏幕缓冲区。
问题是 ReadConsole 没有达到我的预期。
我现在可以使用 ReadConsoleOutput

但是应该注意,此方法是执行此操作的传统方法。 如果你想以“新方式”来做,你应该使用 Pseudo Console Sessions.
它的支持从 Windows 10 1809 和 Windows Server 2019 开始。

还应注意,与匿名管道相比,通过控制台屏幕缓冲区读取 process/program 输出的方法有其缺陷和两个明显的缺点:

  1. 控制台屏幕缓冲区无法填满并阻塞 process/program,但如果到达末尾,新行会将当前第一行推出缓冲区。
  2. 来自 processes/programs 的输出以快速方式向标准输出发送垃圾信息很可能会导致信息丢失,因为您将无法读取、清除和移动控制台屏幕缓冲区中的光标够快了。

我试图通过将控制台屏幕缓冲区 y 大小组件增加到最大可能大小(我发现它是 MAXSHORT - 1)来规避这两个问题,然后等到 process/program 完成。
这对我来说已经足够了,因为我不需要分析或处理彩色输出,而只需在控制台 window 中显示它,它本身仅限于 MAXSHORT - 1 行。
在所有其他情况下,我将使用管道并建议其他人也这样做!

这里是一个简短的版本,没有任何错误处理,可以并行执行而不受干扰(前提是 TStream 对象由线程拥有或线程安全):

procedure CreateProcessWithConsoleCapture(const aAppName, aCMD, aDefaultDir: PChar;
  const CapturedOutput: TStream);
const
  CONSOLE_SCREEN_BUFFER_SIZE_Y = MAXSHORT - 1;
var
  SecurityAttributes: TSecurityAttributes;
  ConsoleHandle: THandle;
  StartupInfo: TStartupInfo;
  ProcessInformation: TProcessInformation;
  CharsRead: Cardinal;
  BufferSize, Origin: TCoord;
  ConsoleScreenBufferInfo: TConsoleScreenBufferInfo;
  Buffer: array of TCharInfo;
  ReadRec: TSmallRect;
begin
  SecurityAttributes.nLength := SizeOf(SecurityAttributes);
  SecurityAttributes.lpSecurityDescriptor := Nil;
  SecurityAttributes.bInheritHandle := True;

  ConsoleHandle := CreateConsoleScreenBuffer(
     GENERIC_READ or GENERIC_WRITE,
     FILE_SHARE_READ or FILE_SHARE_WRITE,
     @SecurityAttributes,
     CONSOLE_TEXTMODE_BUFFER,
     nil);
  
  try
    GetConsoleScreenBufferInfo(ConsoleHandle, ConsoleScreenBufferInfo);
    BufferSize.X := ConsoleScreenBufferInfo.dwSize.X;
    BufferSize.Y := CONSOLE_SCREEN_BUFFER_SIZE_Y;
    SetConsoleScreenBufferSize(ConsoleHandle, BufferSize);

    Origin.X := 0;
    Origin.Y := 0;
    FillConsoleOutputCharacter(ConsoleHandle, #0, BufferSize.X * BufferSize.Y, Origin, CharsRead);

    SetStdHandle(STD_OUTPUT_HANDLE, ConsoleHandle);

    FillChar(StartupInfo, Sizeof(StartupInfo), 0);
    StartupInfo.cb := SizeOf(StartupInfo);
    StartupInfo.hStdOutput := ConsoleHandle;
    StartupInfo.hStdError := ConsoleHandle;
    StartupInfo.hStdInput := GetStdHandle(STD_INPUT_HANDLE);
    StartupInfo.dwFlags := STARTF_USESTDHANDLES or STARTF_FORCEOFFFEEDBACK;

    CreateProcess(aAppName, aCMD,
      @SecurityAttributes, @SecurityAttributes,
      True, NORMAL_PRIORITY_CLASS,
      nil, aDefaultDir,
      StartupInfo, ProcessInformation);

    try
      WaitForSingleObject(ProcessInformation.hProcess, INFINITE);

      GetConsoleScreenBufferInfo(ConsoleHandle, ConsoleScreenBufferInfo);

      BufferSize.X := ConsoleScreenBufferInfo.dwSize.X;
      BufferSize.Y := ConsoleScreenBufferInfo.dwCursorPosition.Y;

      if ConsoleScreenBufferInfo.dwCursorPosition.X > 0 then
      begin
        Inc(BufferSize.Y);
      end;

      ReadRec.Left := 0;
      ReadRec.Top := 0;
      ReadRec.Right := BufferSize.X - 1;
      ReadRec.Bottom := BufferSize.Y - 1;

      SetLength(Buffer, BufferSize.X * BufferSize.Y);
      ReadConsoleOutput(ConsoleHandle, @Buffer[0], BufferSize, Origin, ReadRec);

      CharsRead := SizeOf(TCharInfo) * (ReadRec.Right - ReadRec.Left + 1) * (ReadRec.Bottom - ReadRec.Top + 1);
      if CharsRead > 0 then
      begin
        CapturedOutput.Size := CapturedOutput.Size + CharsRead;
        CapturedOutput.WriteBuffer(Buffer[0], CharsRead);
      end;

    finally
      CloseHandle(ProcessInformation.hProcess);
      CloseHandle(ProcessInformation.hThread);
    end;
  finally
    CloseHandle(ConsoleHandle);
  end;
end;