Skip to content

fix(conpty): correctly implement pidAlive using processalive on Windows#3082

Merged
Pulkit7070 merged 1 commit into
AgentWrapper:mainfrom
AshutoshIIT1234:fix/conpty-pidalive
Jul 25, 2026
Merged

fix(conpty): correctly implement pidAlive using processalive on Windows#3082
Pulkit7070 merged 1 commit into
AgentWrapper:mainfrom
AshutoshIIT1234:fix/conpty-pidalive

Conversation

@AshutoshIIT1234

Copy link
Copy Markdown
Contributor

What

Updates the pidAlive function in backend/internal/adapters/runtime/conpty/pidalive_windows.go to delegate to processalive.Alive.

Why

Fixes #3079.

On Windows, the ConPTY runtime adapter's pidAlive implementation opened a handle to the process with SYNCHRONIZE access but never actually waited on it using WaitForSingleObject. Because Windows keeps process kernel objects alive until all handles are closed, OpenProcess succeeded for recently-terminated processes, incorrectly returning true. This broke the early-exit optimization during session teardown, causing the loop to always hang for the full 500ms deadline.

How

  • Replaced the flawed OpenProcess logic in pidAlive by directly calling processalive.Alive(pid).
  • processalive.Alive is already implemented and proven elsewhere in the codebase, and correctly uses WaitForSingleObject(handle, 0) to check for WAIT_TIMEOUT.
  • Replaced the "golang.org/x/sys/windows" import with "github.com/aoagents/agent-orchestrator/backend/internal/processalive".

Testing

  • Confirmed that this effectively stops returning false positives for terminated processes.
  • Checked that tests pass: cd backend && go build ./... && go test ./...

Checklist

  • [✓] Branched from main
  • [✓] One focused change; links the related issue when applicable
  • [✓] Follows AGENTS.md conventions and PR hygiene
  • Tests added/updated for user-visible behavior where it makes sense (Note: No new tests added as this delegates to the already-tested processalive package).
  • [✓] Relevant CI checks pass for the area touched (go, frontend, etc.)

@Pulkit7070 Pulkit7070 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed. The fix is correct and the bug analysis is accurate.

What I verified:

  • processalive.Alive (windows) opens the SYNCHRONIZE handle and then calls WaitForSingleObject(handle, 0), returning alive only when the result is WAIT_TIMEOUT. That is exactly the missing step: a still-open handle to a recently-terminated process keeps OpenProcess succeeding, but the process object is signaled, so WaitForSingleObject returns WAIT_OBJECT_0 rather than WAIT_TIMEOUT and Alive correctly returns false. Delegating pidAlive to it fixes the false-positive that forced the 500ms teardown hang.
  • Signatures match (Alive(pid int) bool), and Alive exists on both windows and unix builds.
  • Since this file is build-tagged windows, I cross-compiled: GOOS=windows go build and GOOS=windows go vet on the conpty package and processalive are clean, so the new import resolves and there is no leftover unused windows import. Host (darwin) go build ./... is unaffected.

One completeness point worth addressing, since it is the same bug class in the same subtree. backend/internal/adapters/runtime/conpty/ptyregistry/pidalive_windows.go defaultPidAlive uses windows.OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, ...) and treats OpenProcess success as alive, with no WaitForSingleObject check. That is the exact pattern this PR is fixing, so it very likely has the same false-positive on recently-terminated PIDs (it is used for PtyHostPID liveness in ptyregistry/registry.go). Consider routing that one through processalive.Alive in this PR as well, or confirm why it is exempt, so the fix is consistent across both conpty liveness probes.

Minor: no test was added. The conpty pidAlive is now a thin delegation, so a test there would only assert delegation, but there is no regression test pinning the original hang. That is hard to unit test on Windows, so it is non-blocking, though a small processalive-level test for the WAIT_TIMEOUT vs WAIT_OBJECT_0 distinction would be the durable place if you want one.

@Pulkit7070

Pulkit7070 commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator

Follow-up: stress test and edge-case pass.

Stress (what is runnable on a non-Windows host):

  • go test -race -count=10 on internal/processalive: clean, no flakiness or data race.
  • go test -race -count=10 on the conpty package: clean (about 40s), the host-tagged pidAlive path and runtime teardown exercised hard 10x with no race.
  • The Windows runtime path itself cannot be executed on macOS, so this is not a runtime stress of the actual Windows Alive; that requires a Windows host. Cross-compile (GOOS=windows build + vet) is clean, which is the compile-level check.

Edge-case analysis of processalive.Alive on Windows:

  • pid <= 0 returns false. Good, and safer than the old uint32(pid) cast on a negative.
  • OpenProcess on a non-existent pid returns ERROR_INVALID_PARAMETER, which is not ACCESS_DENIED, so Alive returns false. Correct.
  • WaitForSingleObject outcomes reduce to two realistic cases: WAIT_TIMEOUT (not signaled, still running) is alive, WAIT_OBJECT_0 (signaled, exited) is dead. WAIT_FAILED surfaces as an error and returns false; WAIT_ABANDONED does not apply to process handles. So the binary is correct.
  • Handle is closed via defer on every post-open path, no leak.

Two things worth calling out, neither blocking:

  1. Behavior change on ACCESS_DENIED. The old conpty pidAlive returned false on any OpenProcess error, so an unopenable process read as dead. processalive.Alive returns true on ERROR_ACCESS_DENIED (exists but not waitable). That is the more correct semantics, and it does not affect this use case since sess.pid is AO's own pty-host child that the owner can always open with SYNCHRONIZE. Just noting the semantic difference in case any other caller of Alive relied on the old dead-on-denied behavior.
  2. PID reuse. If sess.pid exits and Windows reuses that pid before the probe, Alive can report the reused process as alive and the later force-kill can target it. This is inherent to pid-based liveness and was equally true of the old code, so it is not a regression, but it is a known limitation of this approach.

Severity note for context: the original bug is latency only, not a leak. The caller polls for 500ms and then force-kills sess.pid regardless (runtime.go), so the flawed pidAlive only cost a 500ms teardown stall, it did not leave processes alive. The fix correctly restores the early-exit.

@Pulkit7070
Pulkit7070 merged commit 8cc1346 into AgentWrapper:main Jul 25, 2026
9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Windows ConPTY pidAlive returns true for terminated processes (OpenProcess without WaitForSingleObject)

2 participants