Skip to content

Guard startup terminal probing on Windows when stderr is redirected#37823

Merged
pelikhan merged 5 commits into
mainfrom
copilot/gh-aw-hangs-at-startup-windows
Jun 8, 2026
Merged

Guard startup terminal probing on Windows when stderr is redirected#37823
pelikhan merged 5 commits into
mainfrom
copilot/gh-aw-hangs-at-startup-windows

Conversation

Copilot AI commented Jun 8, 2026

Copy link
Copy Markdown
Contributor

gh aw could hang before any output when launched under Windows pty/pipe wrappers, including on non-interactive commands like version. The hang appears on startup-path terminal capability probing rather than command-specific execution.

  • What changed: startup probe gating (Windows-only)

    • Added a gate around lipgloss compat initialization in pkg/styles/theme.go.
    • On Windows, configureLipglossCompat() now runs only if stderr is confirmed as a console-style character device.
    • If stderr is redirected, pipe-backed, unknown/unset, or Stat fails, startup probing is skipped.
  • What changed: explicit probe policy

    • Introduced shouldConfigureLipglossCompat(goos, stderrMode, statErr) to centralize probe eligibility.
    • Tightened the Windows check to require the real console mode shape: os.ModeDevice | os.ModeCharDevice.
    • Kept non-Windows behavior unchanged (probe still enabled).
  • What changed: focused coverage

    • Added table-driven tests in pkg/styles/theme_test.go for:
      • Windows + console device flags (ModeDevice | ModeCharDevice) → probe enabled
      • Windows + pipe → probe disabled
      • Windows + zero mode / unknown mode → probe disabled
      • Windows + stat error → probe disabled
      • Windows + os.ErrInvalid fallback → probe disabled
      • Non-Windows → probe enabled
func shouldConfigureLipglossCompat(goos string, stderrMode os.FileMode, statErr error) bool {
	if goos == "windows" {
		if statErr != nil {
			return false
		}
		return stderrMode&(os.ModeDevice|os.ModeCharDevice) == (os.ModeDevice | os.ModeCharDevice)
	}
	return true
}

Copilot AI changed the title [WIP] Fix gh aw hang issue at startup on Windows Guard startup terminal probing on Windows when stderr is redirected Jun 8, 2026
Copilot AI requested a review from pelikhan June 8, 2026 14:10
@pelikhan pelikhan marked this pull request as ready for review June 8, 2026 14:11
Copilot AI review requested due to automatic review settings June 8, 2026 14:11

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

This PR prevents gh aw from hanging on startup under certain Windows pty/pipe wrapper environments by conditionally skipping Lipgloss terminal capability probing when stderr is not a character device.

Changes:

  • Added a Windows-only eligibility gate for running configureLipglossCompat() during package init based on os.Stderr.Stat() mode.
  • Centralized the probe policy in shouldConfigureLipglossCompat(goos, stderrMode, statErr).
  • Added table-driven unit tests covering Windows/non-Windows probe eligibility scenarios.
Show a summary per file
File Description
pkg/styles/theme.go Gates Lipgloss compat initialization on Windows to avoid startup hangs when stderr is redirected/pipe-backed.
pkg/styles/theme_test.go Adds targeted tests validating the new probe gating policy.

Copilot's findings

Tip

Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

  • Files reviewed: 2/2 changed files
  • Comments generated: 0

@github-actions

github-actions Bot commented Jun 8, 2026

Copy link
Copy Markdown
Contributor

🧪 Test Quality Sentinel completed test quality analysis.

@github-actions

github-actions Bot commented Jun 8, 2026

Copy link
Copy Markdown
Contributor

Design Decision Gate 🏗️ completed the design decision gate check.

No ADR enforcement needed: PR #37823 does not have the implementation label (has_implementation_label=false) and has 75 new lines in business logic directories, below the 100-line threshold (requires_adr_by_default_volume=false).

@github-actions

github-actions Bot commented Jun 8, 2026

Copy link
Copy Markdown
Contributor

PR Code Quality Reviewer completed the code quality review.

@github-actions

github-actions Bot commented Jun 8, 2026

Copy link
Copy Markdown
Contributor

🧠 Matt Pocock Skills Reviewer has completed the skills-based review. ✅

@github-actions github-actions Bot mentioned this pull request Jun 8, 2026
@github-actions

github-actions Bot commented Jun 8, 2026

Copy link
Copy Markdown
Contributor

🧪 Test Quality Sentinel Report

Test Quality Score: 100/100 — Excellent

Analyzed 4 test scenarios (1 table-driven TestShouldConfigureLipglossCompat): 4 design, 0 implementation, 0 guideline violations.

📊 Metrics & Test Classification (4 scenarios analyzed)
Metric Value
New/modified tests analyzed 4 scenarios (1 table-driven function)
✅ Design tests (behavioral contracts) 4 (100%)
⚠️ Implementation tests (low value) 0 (0%)
Tests with error/edge cases 3 (75%)
Duplicate test clusters 0
Test inflation detected No (1.5:1 ratio — below 2:1 threshold)
🚨 Coding-guideline violations 0

Test Classification Details

Test File Classification Issues Detected
TestShouldConfigureLipglossCompat — windows character device pkg/styles/theme_test.go ✅ Design None
TestShouldConfigureLipglossCompat — windows redirected pipe pkg/styles/theme_test.go ✅ Design None
TestShouldConfigureLipglossCompat — windows stat error pkg/styles/theme_test.go ✅ Design None
TestShouldConfigureLipglossCompat — non-windows still configures pkg/styles/theme_test.go ✅ Design None

Language Support

Tests analyzed:

  • 🐹 Go (*_test.go): 4 scenarios — unit (//go:build !integration && !js && !wasm)

Verdict

Check passed. 0% of new tests are implementation tests (threshold: 30%).

📖 Understanding Test Classifications

Design Tests (High Value) verify what the system does:

  • Assert on observable outputs, return values, or state changes
  • Cover error paths and boundary conditions
  • Would catch a behavioral regression if deleted
  • Remain valid even after internal refactoring

Implementation Tests (Low Value) verify how the system does it:

  • Assert on internal function calls (mocking internals)
  • Only test the happy path with typical inputs
  • Break during legitimate refactoring even when behavior is correct
  • Give false assurance: they pass even when the system is wrong

Goal: Shift toward tests that describe the system's behavioral contract — the promises it makes to its users and collaborators.

🧪 Test quality analysis by Test Quality Sentinel · 130.8 AIC · ⌖ 19.9 AIC ·

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

✅ Test Quality Sentinel: 100/100. Test quality is excellent — 0% of new tests are implementation tests (threshold: 30%). The new table-driven TestShouldConfigureLipglossCompat covers all four branches of the Windows stderr-probe guard with clear behavioral assertions.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Skills-Based Review 🧠

Applied /diagnose and /tdd — approving with minor test-coverage suggestions.

📋 Key Themes & Highlights

Key Themes

  • Test table completeness: three edge cases are worth adding — combined ModeCharDevice | ModeDevice flags, a zero-mode stat-success case, and an explicit os.ErrInvalid entry to trace the init() nil-nil fallback
  • Untestable init() branch: the nil → ErrInvalid conversion is logically covered by the existing os.ErrPermission test, but the traceability is invisible — easy to close with one extra table row or a structural refactor

Positive Highlights

  • ✅ Root cause properly targeted: gating the probe at the source rather than papering over symptoms
  • ✅ Pure function extraction (shouldConfigureLipglossCompat) is exactly the right testability pattern for an init() path
  • ✅ Table-driven tests cover the key decision boundaries (char device / named pipe / stat error / non-Windows)
  • ✅ Non-Windows behaviour deliberately unchanged — minimal blast radius
  • ✅ Defensive nil-nil guard with clear inline comments

🧠 Reviewed using Matt Pocock's skills by Matt Pocock Skills Reviewer · 165.8 AIC · ⌖ 13.8 AIC

Comment thread pkg/styles/theme_test.go Outdated
{
name: "windows character device",
goos: "windows",
stderrMode: os.ModeCharDevice,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[/tdd] The "windows character device" case tests os.ModeCharDevice in isolation. Real Windows console handles frequently report combined modes like os.ModeCharDevice | os.ModeDevice. Adding a combined-flags case would anchor the bitmask semantics for realistic inputs.

💡 Suggested additional test case
{
	name:       "windows character device with device flag",
	goos:       "windows",
	stderrMode: os.ModeCharDevice | os.ModeDevice,
	want:       true,
},

The implementation uses stderrMode&os.ModeCharDevice != 0, which handles combined flags correctly — this test would make that intent part of the explicit spec.

Comment thread pkg/styles/theme_test.go
stderrMode: os.ModeNamedPipe,
statErr: os.ErrPermission,
want: true,
},

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[/tdd] Consider adding a "windows stat succeeds, zero mode" case: goos: "windows", stderrMode: os.FileMode(0), statErr: nil, want: false. This pins the behaviour when stat returns a FileMode with no device flags set — a plausible handle state (e.g. a virtual file handle that stat-succeeds but reports nothing) that is currently untested.

💡 Suggested test case
{
	name:       "windows zero mode stat success",
	goos:       "windows",
	stderrMode: os.FileMode(0),
	want:       false,
},

Since init() initialises stderrMode to os.FileMode(0) before reading stderrInfo.Mode(), this also documents the fallback default intentionally.

Comment thread pkg/styles/theme.go
// impossible state as an invalid stderr handle so Windows startup probing is
// safely skipped.
if statErr == nil && stderrInfo == nil {
statErr = os.ErrInvalid

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[/diagnose] The nil-nil → os.ErrInvalid conversion here cannot be exercised from the shouldConfigureLipglossCompat test table — the pure function always receives already-converted values. The existing "windows stat error" case uses os.ErrPermission, which is equivalent at runtime, but the traceability from this init() fallback to its test coverage is invisible.

💡 Options to close the gap

Option A — add an os.ErrInvalid case to the test table with a comment linking it to this path:

{
	name:    "windows ErrInvalid (init nil-nil fallback)",
	goos:    "windows",
	statErr: os.ErrInvalid,
	want:    false,
},

Option B — hoist the nil-nil guard into shouldConfigureLipglossCompat by passing stderrInfo directly (or a (mode, modeValid bool) pair), making the entire decision testable through the pure function.

Option A is a lower-effort win; Option B is structurally cleaner.

@pelikhan

pelikhan commented Jun 8, 2026

Copy link
Copy Markdown
Collaborator

@copilot apply copilot-review skill

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The fix is logically sound and addresses the stated Windows hang scenario cleanly. Three non-blocking observations on test fidelity and check precision.

### Summary of findings

Test uses os.ModeCharDevice alone, not the real production mode (pkg/styles/theme_test.go:294): Windows FILE_TYPE_CHAR (console) handles cause Go to set os.ModeDevice | os.ModeCharDevice together. The test case uses only os.ModeCharDevice, which works today but will silently become a false negative if the check is ever hardened to require both bits.

Zero-mode case (stderrMode=0, statErr=nil) is not tested (pkg/styles/theme_test.go:315): The init() comment explicitly documents this as the "unknown/unset" conservative default on Windows, but this input is absent from the table. A future change that treats zero-mode as "assume TTY" would not be caught.

os.ModeCharDevice check is semantically imprecise (pkg/styles/theme.go:71): Go docs define ModeCharDevice as meaningful "when ModeDevice is set". The two-bit equality check stderrMode&(os.ModeDevice|os.ModeCharDevice) == (os.ModeDevice|os.ModeCharDevice) is both more precise and consistent with the suggested test fix above.

Warning

Firewall blocked 2 domains

The following domains were blocked by the firewall during workflow execution:

  • patchdiff.githubusercontent.com
  • pkg.go.dev

To allow these domains, add them to the network.allowed list in your workflow frontmatter:

network:
  allowed:
    - defaults
    - "patchdiff.githubusercontent.com"
    - "pkg.go.dev"

See Network Configuration for more information.

🔎 Code quality review by PR Code Quality Reviewer · 10.1 AIC · ⌖ 13.3 AIC

Comment thread pkg/styles/theme_test.go Outdated
{
name: "windows character device",
goos: "windows",
stderrMode: os.ModeCharDevice,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Test uses single-bit os.ModeCharDevice, not the actual production value: on Windows, FILE_TYPE_CHAR (console) handles cause Go to set os.ModeDevice | os.ModeCharDevice together — never ModeCharDevice alone.

💡 Why this matters

The current check is stderrMode&os.ModeCharDevice != 0, so both values satisfy it today. But the test case supplies a value that cannot arise in practice and creates a latent trap: if the check is ever tightened to the semantically precise form:

return stderrMode&(os.ModeDevice|os.ModeCharDevice) == (os.ModeDevice|os.ModeCharDevice)

the existing os.ModeCharDevice-only case would become a false negative — the test would pass a value the stricter check rejects, masking the regression.

Suggested fix: use the real Windows console mode shape:

{
    name:       "windows character device",
    goos:       "windows",
    stderrMode: os.ModeDevice | os.ModeCharDevice, // FILE_TYPE_CHAR on Windows
    want:       true,
},

Comment thread pkg/styles/theme_test.go
stderrMode: os.ModeNamedPipe,
statErr: os.ErrPermission,
want: true,
},

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Missing test case for stderrMode=0, statErr=nil on Windows: the init() comment explicitly calls this the "unknown/unset" conservative default, but shouldConfigureLipglossCompat("windows", 0, nil) is never in the table.

💡 Suggested addition

The stderrMode := os.FileMode(0) fallback in init() is justified by: "Zero mode means 'unknown/unset'; on Windows this keeps the startup probe disabled unless stderr is explicitly confirmed as a character device." A test should pin this:

{
    name:       "windows unknown mode (zero) no error",
    goos:       "windows",
    stderrMode: os.FileMode(0),
    statErr:    nil,
    want:       false,
},

Without this, a refactor that treats zero-mode as "assume TTY" would go undetected.

Comment thread pkg/styles/theme.go Outdated
if statErr != nil {
return false
}
return stderrMode&os.ModeCharDevice != 0

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

os.ModeCharDevice alone is semantically imprecise: Go's fs package documents ModeCharDevice as "Unix character device, when ModeDevice is set." The check should require both bits:

💡 Suggested fix and rationale
// Before
return stderrMode&os.ModeCharDevice != 0

// After
return stderrMode&(os.ModeDevice|os.ModeCharDevice) == (os.ModeDevice|os.ModeCharDevice)

On Windows, Go sets ModeDevice | ModeCharDevice together for FILE_TYPE_CHAR handles, so the current single-bit check happens to work. But accepting ModeCharDevice without ModeDevice is technically a false-positive admission of an invalid mode shape.

This also brings the check into alignment with the test fix suggested on the stderrMode: os.ModeCharDevice case — if the test is updated to use os.ModeDevice | os.ModeCharDevice (the real production value), the stricter check here would correctly accept it, while the current looser check would accept anything with just ModeCharDevice set.

Copilot AI commented Jun 8, 2026

Copy link
Copy Markdown
Contributor Author

@copilot apply copilot-review skill

Applied. Fixed the in-scope review feedback in 0406514 by tightening the Windows console-mode check and extending the table tests for the realistic combined flags, zero-mode success, and explicit os.ErrInvalid fallback cases.

@pelikhan pelikhan merged commit 01a0220 into main Jun 8, 2026
@pelikhan pelikhan deleted the copilot/gh-aw-hangs-at-startup-windows branch June 8, 2026 14:36
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.

gh aw hangs at startup when invoked under a pty/pipe wrapper on Windows (speculative)

3 participants