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

问题描述

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

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

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

    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,@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,True,norMAL_PRIORITY_CLASS,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 为每个子进程创建额外的控制台缓冲区并使用 ReadConsole 读取内容,但这并不成功,因为 ReadConsole 返回系统错误 6 (ERROR_INVALID_HANDLE)。

    ConsoleHandle := CreateConsoleScreenBuffer(
       GENERIC_READ or GENERIC_WRITE,FILE_SHARE_READ or FILE_SHARE_WRITE,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 sequencesAllocConsoleAttachConsoleFreeConsole,但无法完全理解我的用例。

保存/接收子进程控制台输出的着色信息的正确/最佳方法是什么?

解决方法

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

然而,应该注意的是,这种方法是传统的做法。 如果您想以“新方式”进行操作,您可能应该使用 Pseudo Console Sessions
它的支持从 Windows 10 1809 和 Windows Server 2019 开始。

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

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

我试图通过将控制台屏幕缓冲区 y 大小组件增加到其最大可能大小(我发现它是 MAXSHORT - 1)并等到进程/程序完成来规避两者。
这对我来说已经足够了,因为我不需要分析或处理彩色输出,而只需将其显示在控制台窗口中,控制台窗口本身仅限于 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,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,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;