WinForms+psql 工具卡死完整排查筆記

| .NET | 1 Reads

關鍵詞ProcessStartInfo、管道死鎖、stdout/stderr、-w、非同步 I/O


背景

  • 工具:自行開發的 WinForms「PgBackupRestoreTool」

  • 功能:點擊 Connect → 呼叫 psql -l 測試連線

  • 環境:PostgreSQL 15、Windows 10

同一程式在 PC-A 正常、在 PC-B 進度條無限旋轉。
手動 CLI 測試卻一切 OK:

$env:PGPASSWORD = 'admin'
psql -U admin -h localhost -d bks -l   # 立即回應

伺服器與帳號密碼皆正常。


重現卡死

string err = p.StandardError.ReadToEnd(); // 永遠不返回
  • psql.exe CPU 0 % → OS 將其暫停

  • 未出現「Password:」提示

  • 兩機 psql 版本相同,無防毒攔截

判斷為 stdout 填滿匿名管道→psql 被掛起,而父行程卡在 ReadToEnd()


根本原因

階段 動作 結果
① 工具執行 psql -l -l 把所有資料庫列表寫到 stdout 庫數愈多、輸出愈大
② 程式僅重定向 stderr stdout 無人讀取 約 4 KB 填滿管道
③ 管道滿 → OS 停 psql stderr 無資料可讀 Parent 卡在 ReadToEnd()
④ UI 只剩轉圈 psql 僵住 看似等密碼,實為死鎖

PC-A 庫較少,stdout 未塞滿;新建多個 DB 後亦能重現。


修正要點

  1. 同時重定向 stdout 和 stderr

  2. 非同步並發讀流,杜絕阻塞

  3. 加上 -w,若缺密碼立刻失敗

  4. 若不需 DB 列表,可改 -qAt -c "SELECT 1",輸出極小

下面把 「問題程式碼」(Before)「修正版」(After) 同時列出,方便「前後對照」。


Before - 會卡在 ReadToEnd() 的版本

private bool RunProcessAndCheckSuccess(string exe, IEnumerable<string> args)
{
    bool ok = false;
    try
    {
        var psi = new ProcessStartInfo
        {
            FileName = exe,
            RedirectStandardError = true,   // 只重定向 stderr
            UseShellExecute = false,
            CreateNoWindow = true
        };

        psi.Environment["PGPASSWORD"]       = PG_PASSWORD;
        psi.Environment["PGCLIENTENCODING"] = "UTF8";
        foreach (var a in args) psi.ArgumentList.Add(a);

        using var p = Process.Start(psi)!;

        // ① 只讀 stderr,stdout 無人處理
        string err = p.StandardError.ReadToEnd();

        // ② psql stdout 寫滿後被 OS 暫停 → 這裡永遠不往下走
        p.WaitForExit();

        ok = p.ExitCode == 0;
        if (!ok && !string.IsNullOrEmpty(err))
            Log(err.Trim(), Color.Red);
    }
    catch (Exception ex)
    {
        Log($"RunProcessAndCheckSuccess error: {ex.Message}", Color.Red);
    }
    return ok;
}

After - 非同步讀雙流、永不堵塞

private async Task<bool> RunProcessAndCheckSuccessAsync(string exe, IEnumerable<string> args)
{
    try
    {
        var psi = new ProcessStartInfo
        {
            FileName               = exe,
            RedirectStandardOutput = true,   // 同時重定向 stdout
            RedirectStandardError  = true,
            UseShellExecute        = false,
            CreateNoWindow         = true
        };

        psi.Environment["PGPASSWORD"]       = PG_PASSWORD;
        psi.Environment["PGCLIENTENCODING"] = "UTF8";

        psi.ArgumentList.Add("-w");                 // no-password prompt
        foreach (var a in args) psi.ArgumentList.Add(a);

        using var p = Process.Start(psi)!;

        // 非同步並發讀取,避免死鎖
        var stdTask = p.StandardOutput.ReadToEndAsync();
        var errTask = p.StandardError .ReadToEndAsync();
        await Task.WhenAll(stdTask, errTask, p.WaitForExitAsync());

        if (!string.IsNullOrWhiteSpace(stdTask.Result))
            Log(stdTask.Result.Trim(), Color.Black);

        if (p.ExitCode != 0 && !string.IsNullOrWhiteSpace(errTask.Result))
            Log(errTask.Result.Trim(), Color.Red);

        return p.ExitCode == 0;
    }
    catch (Exception ex)
    {
        Log($"RunProcessAndCheckSuccessAsync error: {ex.Message}", Color.Red);
        return false;
    }
}

呼叫端範例

// 在 async 方法內
bool ok = await RunProcessAndCheckSuccessAsync("psql", args);

核心差異一覽

舊版 新版
只重定向 stderr 同時重定向 stdout / stderr
同步 ReadToEnd() → 容易堵塞 ReadToEndAsync() + WaitForExitAsync()
-w → 密碼缺失時進入互動等待 -w 讓 psql 立即報錯退出
方法為同步 (bool) 方法改為 async Task<bool>,不阻塞 UI

將這段 After 取代舊實作、並把舊呼叫改成 await RunProcessAndCheckSuccessAsync(...) 即可完全解決「進度條無限轉」問題。

最終程式碼(純英文註解)

private async Task<bool> RunProcessAndCheckSuccessAsync(string exe, IEnumerable<string> args)
{
    var psi = new ProcessStartInfo
    {
        FileName               = exe,
        RedirectStandardOutput = true,
        RedirectStandardError  = true,
        UseShellExecute        = false,
        CreateNoWindow         = true
    };

    psi.Environment["PGPASSWORD"]       = PG_PASSWORD;
    psi.Environment["PGCLIENTENCODING"] = "UTF8";

    psi.ArgumentList.Add("-w");
    foreach (var a in args) psi.ArgumentList.Add(a);

    using var p = Process.Start(psi)!;

    var stdTask = p.StandardOutput.ReadToEndAsync();
    var errTask = p.StandardError .ReadToEndAsync();
    await Task.WhenAll(stdTask, errTask, p.WaitForExitAsync());

    if (!string.IsNullOrWhiteSpace(stdTask.Result))
        Log(stdTask.Result.Trim(), Color.Black);

    if (p.ExitCode != 0 && !string.IsNullOrWhiteSpace(errTask.Result))
        Log(errTask.Result.Trim(), Color.Red);

    return p.ExitCode == 0;
}

呼叫端:

bool ok = await RunProcessAndCheckSuccessAsync("psql", args);

驗證

測試 PC-A PC-B
原本庫數 < 200 ms < 200 ms
+100 空庫 同樣 < 200 ms 同樣 < 200 ms

再無僵屍 psql 行程。


經驗總結

  1. 啟動 CLI 工具時,stdout 與 stderr 最好一起重定向

  2. 優先使用非同步 I/OReadToEndAsyncWaitForExitAsync

  3. -w--no-password 能防止未帶密碼時進入互動等待。

  4. 單機抓不到 bug 時,把程式搬到另一台機器比對環境差異,很快能定位問題。

問題圓滿解決,工具再次秒速連線 🏁

This article was last edited at