How I Unblocked a Hanging WinForms + psql Tool

| .NET | 0 Reads

Keywords: ProcessStartInfo, pipe dead-lock, stdout / stderr, -w, async I/O


Background

  • App: a custom WinForms “PgBackupRestoreTool”

  • Check: on Connect it spawns psql -l to test the connection

  • Stack: PostgreSQL 15 on Windows 10

Everything worked on PC-A, but on PC-B the progress bar spun forever.
Manual CLI tests succeeded:

$env:PGPASSWORD = 'admin'
psql -U admin -h localhost -d bks -l   # instant response

So the server, credentials and psql itself were fine.


Re-creating the Hang

string err = p.StandardError.ReadToEnd(); // never returns
  • psql.exe sat at 0 % CPU – the OS had suspended it.

  • No “Password:” prompt appeared.

  • Both machines used PostgreSQL 15; no antivirus alerts.

The only plausible culprit: stdout had filled its pipe buffer, blocking psql, while the parent process waited on stderr.


Root Cause

Step Action Result
① Tool launches psql -l -l prints all database rows to stdout More databases → more output
② Code redirects stderr only No one consumes stdout Buffer ≈ 4 KB fills
③ Buffer full → OS suspends psql stderr has nothing to read Parent blocked in ReadToEnd()
④ UI spins psql frozen Looks like a password wait, but it’s a pipe dead-lock

PC-A simply had fewer databases, so stdout never filled; after I created extra DBs it reproduced the hang.


Problem code (Before)

private bool RunProcessAndCheckSuccess(string exe, IEnumerable<string> args)
{
    bool ok = false;
    try
    {
        var psi = new ProcessStartInfo
        {
            FileName = exe,
            RedirectStandardError = true,   // stderr only
            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)!;

        // stdout is NOT redirected → pipe can fill → psql freezes
        string err = p.StandardError.ReadToEnd();
        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;
}

Fixed version (After)

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

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

        psi.ArgumentList.Add("-w");                 // skip 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;
    }
}

Caller example

// The caller must be async
bool ok = await RunProcessAndCheckSuccessAsync("psql", args);

Key changes

  1. Redirect both stdout and stderr to prevent pipe overflow dead-locks.

  2. Use ReadToEndAsync + WaitForExitAsync for non-blocking, dead-lock-free I/O.

  3. Add -w (or --no-password) so psql exits immediately if the password is missing.

  4. Method signature switched to async Task<bool>; a synchronous caller can use
    .GetAwaiter().GetResult() if truly necessary.

After this patch the tool connects instantly, no more infinite spinner regardless of how many databases exist.


Verification

Test PC-A PC-B
Original DB count < 200 ms < 200 ms
+100 empty DBs still < 200 ms still < 200 ms

No more zombie psql processes.


Lessons Learned

  1. Always redirect both stdout and stderr when spawning CLI tools.

  2. Prefer async I/O (ReadToEndAsync, WaitForExitAsync) over synchronous reads.

  3. Use -w (or --no-password) to prevent interactive stalls.

  4. If an issue appears on one PC only, clone the setup to another machine and diff—environmental gaps surface fast.

Problem solved, tool back to instant connects.🏁

This article was last edited at