From b18a9a7b8187408a232ad4d4d6753a04b00820bd Mon Sep 17 00:00:00 2001 From: ShutenOishi Date: Sun, 4 Oct 2026 17:26:40 +0900 Subject: [PATCH 1/4] Reproduce legacy completion context dependence before changing execution --- docs/phase-8-net48-investigation.md | 20 +++++++ tests/PgCliSharp.TestProcess/Program.cs | 5 ++ tests/PgCliSharp.Tests/ProcessRunnerTests.cs | 38 +++++++++++++ tests/PgCliSharp.Tests/PsqlCompletionTests.cs | 56 +++++++++++++++++++ .../QueuedSynchronizationContext.cs | 24 ++++++++ 5 files changed, 143 insertions(+) create mode 100644 tests/PgCliSharp.Tests/QueuedSynchronizationContext.cs diff --git a/docs/phase-8-net48-investigation.md b/docs/phase-8-net48-investigation.md index ddbc290..77d67c7 100644 --- a/docs/phase-8-net48-investigation.md +++ b/docs/phase-8-net48-investigation.md @@ -17,6 +17,26 @@ in the five-byte case during stress round 2. The latter parent trace records `drained`. These prove neither child process exit nor the identity of the pending task. Later successful runs do not establish a root-cause fix. +PR #24 passed all 18 PR CI jobs (37187722869), but exact-main CI 37188258263 +at `1e794140c80b2d0092f2d1a4ae0ceb8d85d790fc` reproduced the zero-byte stall +in net48 stress round 3. The failed run is retained without retry. +Artifact 11297931986, SHA-256 +`fb7e8b8a9a479e52035240cbda75dae890df0bfa2712a02a314c7874d299613b`, +was independently downloaded and checked. The actual CLR4 dump reader succeeded; +all four CliWrap stdIn/stdOut/stdErr/process tasks had flags `0x7000400` +(RanToCompletion set, Faulted/Canceled unset), while its ExecuteAsync state machine +remained at state 2 and wrapper completion flags were `0x2000400` (pending). +Thus, for this capture, unfinished process/pipe tasks do not explain the stall. +Reference-source Task flag definitions and pinned CliWrap/PolyShim source inform +the next investigation; they do not alone identify a historical root cause. + +A follow-up adds deterministic session/one-shot regressions: start under a context +that queues callbacks without pumping, restore the caller's original context, +then require completion within five seconds without any posts to the occupied +startup context. Cleanup releases queued baseline continuations. Start-failure +coverage separately requires caller-context restoration. Before changing runtime +behavior, Windows CI must demonstrate the failing baseline. + The prepared ClrMD reader had not previously compiled or read a dump in CI. Windows now first captures an owned net48 `wait` helper using `MiniDumpWriteDump` and requires CLR4, thread and fixture-managed frames from the actual dump. diff --git a/tests/PgCliSharp.TestProcess/Program.cs b/tests/PgCliSharp.TestProcess/Program.cs index 9bb5335..4620891 100644 --- a/tests/PgCliSharp.TestProcess/Program.cs +++ b/tests/PgCliSharp.TestProcess/Program.cs @@ -8,6 +8,11 @@ void Record(string stage) Console.Error.WriteLine("ready"); Console.Error.Flush(); Record("ready"); +if (args.Length == 2 && args[0] == "gate") +{ + // The parent releases this owned fixture only after startup has returned. + while (!File.Exists(args[1])) Thread.Sleep(10); +} if (args.Length == 1 && args[0] == "produce") { using Stream producer = Console.OpenStandardOutput(); diff --git a/tests/PgCliSharp.Tests/ProcessRunnerTests.cs b/tests/PgCliSharp.Tests/ProcessRunnerTests.cs index 6ad38d4..6fc3557 100644 --- a/tests/PgCliSharp.Tests/ProcessRunnerTests.cs +++ b/tests/PgCliSharp.Tests/ProcessRunnerTests.cs @@ -6,6 +6,44 @@ namespace PgCliSharp.Tests; public sealed class ProcessRunnerTests { + [Fact] + public async Task RunAsync_DoesNotRequireTheStartupSynchronizationContext() + { + using var input = new MemoryStream(); + using var output = new MemoryStream(); + var context = new QueuedSynchronizationContext(); + SynchronizationContext? original = SynchronizationContext.Current; + string gate = Path.Combine(Path.GetTempPath(), "pgcli-context-" + Guid.NewGuid().ToString("N")); + (string executable, string[] arguments) = ManagedTestProcess.Command("gate", gate); + Task completion; + try + { + SynchronizationContext.SetSynchronizationContext(context); + completion = new ProcessRunner().RunAsync(new ProcessRunRequest(executable, arguments, output, TimeSpan.FromSeconds(15), standardInput: input), CancellationToken.None); + Assert.Same(context, SynchronizationContext.Current); + } + finally + { + SynchronizationContext.SetSynchronizationContext(original); + } + + try + { + File.WriteAllText(gate, string.Empty); + Task winner = await Task.WhenAny(completion, Task.Delay(TimeSpan.FromSeconds(5))); + Assert.True(winner == completion, $"Completion required the occupied startup context; observed posts: {context.PostCount}."); + Assert.Equal(0, (await completion).ExitCode); + Assert.Equal(0, context.PostCount); + Assert.True(input.CanRead); + Assert.True(output.CanWrite); + } + finally + { + context.Release(); + File.Delete(gate); + } + } + [Fact] public async Task RunAsync_Timeout_TerminatesProcessAndThrowsTimeoutException() { diff --git a/tests/PgCliSharp.Tests/PsqlCompletionTests.cs b/tests/PgCliSharp.Tests/PsqlCompletionTests.cs index 6123257..4729b95 100644 --- a/tests/PgCliSharp.Tests/PsqlCompletionTests.cs +++ b/tests/PgCliSharp.Tests/PsqlCompletionTests.cs @@ -5,6 +5,62 @@ namespace PgCliSharp.Tests; public sealed class PsqlCompletionTests { + [Fact] + public async Task CompleteAsync_DoesNotRequireTheStartupSynchronizationContext() + { + using var output = new MemoryStream(); + using var error = new MemoryStream(); + var context = new QueuedSynchronizationContext(); + SynchronizationContext? original = SynchronizationContext.Current; + (string executable, string[] arguments) = ManagedTestProcess.Command(); + IProcessSession process; + try + { + SynchronizationContext.SetSynchronizationContext(context); + process = new ProcessSessionRunner().Start(new ProcessSessionStartRequest(executable, arguments, output, error, TimeSpan.FromSeconds(15)), CancellationToken.None); + Assert.Same(context, SynchronizationContext.Current); + } + finally + { + SynchronizationContext.SetSynchronizationContext(original); + } + + using var session = new PsqlSession(process, executable, new Version(18, 6), "18.6"); + try + { + Task completion = session.CompleteAsync(); + Task winner = await Task.WhenAny(completion, Task.Delay(TimeSpan.FromSeconds(5))); + Assert.True(winner == completion, $"Completion required the occupied startup context; observed posts: {context.PostCount}."); + Assert.Equal(PsqlExitStatus.Success, (await completion).Status); + Assert.Equal(0, context.PostCount); + Assert.True(output.CanWrite); + Assert.True(error.CanWrite); + } + finally + { + // Release any baseline regression continuations before disposing caller streams. + context.Release(); + } + } + + [Fact] + public void StartFailure_PreservesTheCallersSynchronizationContext() + { + var context = new QueuedSynchronizationContext(); + SynchronizationContext? original = SynchronizationContext.Current; + try + { + SynchronizationContext.SetSynchronizationContext(context); + string absent = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"), "absent.exe"); + Assert.Throws(() => new ProcessSessionRunner().Start(new ProcessSessionStartRequest(absent, Array.Empty(), Stream.Null, Stream.Null), CancellationToken.None)); + Assert.Same(context, SynchronizationContext.Current); + } + finally + { + SynchronizationContext.SetSynchronizationContext(original); + } + } + [Theory] [InlineData(0)] [InlineData(5)] diff --git a/tests/PgCliSharp.Tests/QueuedSynchronizationContext.cs b/tests/PgCliSharp.Tests/QueuedSynchronizationContext.cs new file mode 100644 index 0000000..5956717 --- /dev/null +++ b/tests/PgCliSharp.Tests/QueuedSynchronizationContext.cs @@ -0,0 +1,24 @@ +using System.Collections.Concurrent; + +namespace PgCliSharp.Tests; + +// Models a caller whose context is occupied until execution has completed. +internal sealed class QueuedSynchronizationContext : SynchronizationContext +{ + private readonly ConcurrentQueue<(SendOrPostCallback Callback, object? State)> _callbacks = new(); + private int _posts; + + internal int PostCount => Volatile.Read(ref _posts); + + public override void Post(SendOrPostCallback d, object? state) + { + Interlocked.Increment(ref _posts); + _callbacks.Enqueue((d, state)); + } + + internal void Release() + { + while (_callbacks.TryDequeue(out var callback)) + callback.Callback(callback.State); + } +} From 7905b1be5d1613f5bbb6e6724a498575382c6d12 Mon Sep 17 00:00:00 2001 From: ShutenOishi Date: Sun, 4 Oct 2026 17:33:36 +0900 Subject: [PATCH 2/4] Isolate CliWrap startup from caller synchronization context --- ...-legacy-startup-synchronization-context.md | 61 +++++++++++++++++++ docs/adr/README.md | 1 + docs/architecture.md | 6 ++ docs/phase-8-net48-investigation.md | 30 +++++++-- docs/roadmap.md | 6 ++ eng/analyze_windows_hang.py | 2 +- .../Internal/Execution/CliWrapExecution.cs | 24 ++++++++ .../Internal/Execution/ProcessRunner.cs | 2 +- .../Execution/ProcessSessionRunner.cs | 2 +- 9 files changed, 126 insertions(+), 8 deletions(-) create mode 100644 docs/adr/0023-isolate-legacy-startup-synchronization-context.md create mode 100644 src/PgCliSharp/Internal/Execution/CliWrapExecution.cs diff --git a/docs/adr/0023-isolate-legacy-startup-synchronization-context.md b/docs/adr/0023-isolate-legacy-startup-synchronization-context.md new file mode 100644 index 0000000..2074ce8 --- /dev/null +++ b/docs/adr/0023-isolate-legacy-startup-synchronization-context.md @@ -0,0 +1,61 @@ +# ADR-0023: Isolate the legacy backend's startup synchronization context + +- Status: Accepted +- Date: 2026-10-04 +- Related: ADR-0008, ADR-0013, ADR-0014, ADR-0017 + +## Context + +Exact-main CI 37188258263 reproduced a net48 completion stall. The actual CLR4 +dump showed all four CliWrap process/input/output/error tasks normally completed +while the outer async enumeration and wrapper completion remained pending. +This narrows the captured incident but does not by itself identify which caller +context or continuation mechanism caused the historical stall. + +Pinned CliWrap 3.10.5 uses an unconfigured `await foreach` over its internalized +PolyShim Task.WhenEach. Awaiting the returned command with ConfigureAwait(false) +does not prevent the dependency's own awaits from capturing the startup context. +Draft PR #25 baseline CI 37188939390 confirms this separate, deterministic defect: +session and one-shot execution both fail to complete under an occupied queued +context on net48 (two posts each); modern targets pass. The one-shot fixture is +gated until startup returns, preventing a synchronous early-exit false positive. + +## Decision + +Keep the target matrix, CliWrap 3.10.5 dependency and shell-free execution policy. +Initialize CliWrap's command task synchronously under a null SynchronizationContext +for both finite and redirected execution. Restore the caller's context in finally, +including synchronous startup failures. Continue observing the returned task with +ConfigureAwait(false), mapping results/failures through existing PgCliSharp types. + +Do not wrap startup in Task.Run, block on asynchronous startup, suppress the wider +ExecutionContext, or change culture/cancellation/timeout/stream ownership. This +scope only prevents capture of the caller's SynchronizationContext by dependency +initialization; it does not promise arbitrary custom TaskScheduler independence. +No public API or runtime dependency changes are needed. + +## Consequences and validation + +The compatibility backend's internal completion no longer requires the caller's +startup synchronization context to pump callbacks. The caller context is restored +before Start returns and after start errors. Existing synchronous startup error +translation remains intact. + +The deterministic regressions must turn green on net48 and remain green on modern +targets; run the existing full suites, bounded Windows 30/3/3 stress, real PostgreSQL +and candidate audits at the final PR head and exact main commit. Preserve failed +baseline/main runs, and inspect actual diagnostic artifacts. Diagnostic heap filters +also include the dependency's internalized MemberPolyfills types, whose names do +not contain the PolyShim namespace. + +The reproduced context defect is confirmed; attributing every historical stall to +it would exceed the evidence. A passing bounded run is regression evidence, not +proof of absence of all intermittent process defects. 1.0 acceptance and postponed +publication remain separate. + +## Primary-source evidence + +- [Pinned CliWrap execution](https://github.com/Tyrrrz/CliWrap/blob/3.10.5/CliWrap/Command.Execution.cs) +- [Pinned PolyShim WhenEach](https://github.com/Tyrrrz/PolyShim/blob/2.12.2/PolyShim/Net90/Task.cs) +- [Framework Task state flags](https://github.com/microsoft/referencesource/blob/main/mscorlib/system/threading/Tasks/Task.cs) +- [Investigation and preserved receipts](../phase-8-net48-investigation.md) diff --git a/docs/adr/README.md b/docs/adr/README.md index df1c438..cd96202 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -80,6 +80,7 @@ Do not silently edit an old Accepted ADR to make history appear different. | [0020](0020-select-unpublished-full-wrapper-preview.md) | Select an unpublished full-wrapper preview | Superseded | | [0021](0021-finish-preview-engineering-without-publication.md) | Finish preview engineering without publication | Accepted | | [0022](0022-require-three-os-native-cli-checkpoint.md) | Require a three-OS native CLI checkpoint for 1.0 | Accepted | +| [0023](0023-isolate-legacy-startup-synchronization-context.md) | Isolate the legacy backend's startup synchronization context | Accepted | ## Template diff --git a/docs/architecture.md b/docs/architecture.md index 5b34ffe..411fc26 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -152,6 +152,12 @@ Requirements: The `netstandard2.0` compatibility backend must map execution results, cancellation, timeout, and failures back into PgCliSharp's own result/exception model. Public behavior should remain consistent across target frameworks. +Under ADR-0023, both legacy finite and session backends initialize CliWrap +synchronously with SynchronizationContext temporarily cleared, restoring the +caller context in finally. Internal completion therefore does not require the +startup context to pump callbacks. Start errors remain synchronous; no Task.Run, +ExecutionContext suppression, public API or dependency change is introduced. + Phase 6 additionally separates finite one-shot execution from long-lived redirected process sessions under ADR-0013. A redirected `psql` session exposes programmatic duplex pipe I/O but is not a TTY/PTY and does not promise Readline, command-history, or terminal-emulation behavior. Long-running rich-I/O tools may stream both stdout and stderr to caller-owned destinations so memory usage does not have to grow with process duration. ADR-0014 makes modern session output faults part of lifecycle supervision and bounds the `netstandard2.0` CliWrap input bridge to approximately 1 MiB. Legacy `WriteAsync` therefore applies cancellation-aware backpressure; successful write completion means acceptance into the bounded delivery buffer, while `CompleteInput` drains already accepted bytes before EOF. ## 9. Output model diff --git a/docs/phase-8-net48-investigation.md b/docs/phase-8-net48-investigation.md index 77d67c7..1e90088 100644 --- a/docs/phase-8-net48-investigation.md +++ b/docs/phase-8-net48-investigation.md @@ -35,7 +35,18 @@ that queues callbacks without pumping, restore the caller's original context, then require completion within five seconds without any posts to the occupied startup context. Cleanup releases queued baseline continuations. Start-failure coverage separately requires caller-context restoration. Before changing runtime -behavior, Windows CI must demonstrate the failing baseline. +behavior, Windows CI must demonstrate the failing baseline. Baseline CI 37188939390 +at PR #25 head `b18a9a7b8187408a232ad4d4d6753a04b00820bd` confirms two net48 +failures: both controlled-context completions time out with two posts each. +Modern net8/net10 suites each pass 554 tests, and the start-error restoration test +passes on all targets. This is a deterministic context-dependence defect. + +ADR-0023 fixes it by initializing the existing CliWrap command task synchronously +under a null SynchronizationContext and restoring the caller's context in finally. +Both finite and session runners use the same internal adapter. No Task.Run or +wider ExecutionContext suppression is used. Final exact PR/main CI and artifact +inspection remain the acceptance gate for this change. The known defect and the +historical captured stall are distinguished; no claim covers every past timeout. The prepared ClrMD reader had not previously compiled or read a dump in CI. Windows now first captures an owned net48 `wait` helper using `MiniDumpWriteDump` @@ -54,8 +65,10 @@ PR/main CI and inspection of actual smoke/stress artifacts are required before this diagnostic change is accepted. A successful bounded run is preparedness and regression evidence, not proof that the intermittent defect is resolved. -No production execution behavior, public API, frozen preview source or publication -settings change. 1.0 acceptance and external publication remain pending. +The initial PR #24 diagnostics did not change production execution. The PR #25 +follow-up deliberately changes only legacy startup context capture under ADR-0023. +Public API, runtime dependencies, frozen preview source and publication settings +remain unchanged. 1.0 acceptance and external publication remain pending. ## 日本語 @@ -70,5 +83,12 @@ CLR4・スレッド・fixtureの管理フレームを必須とし、子は後片 net48反復は30回を上限に最初の失敗で止め、既存のタイムアウトは維持します。 生ダンプは削除しアップロードからも除外します。文字列や環境値を出力せず、 スタック・型・アドレス・Task状態のテキストを保持します。 -成功した場合も原因解決とは扱いません。実行実装・公開API・固定候補・公開設定は -変更せず、1.0判定と外部公開は保留します。 +PR #24のmainで再現した停止の実ダンプを解析し、下位4Taskの正常完了と上位処理の +保留を確認しました。PR #25の修正前CI 37188939390では、処理しない同期コンテキストを +使う回帰テストがnet48の通常実行・セッション双方で失敗し、それぞれ2回のPostを +観測しました。modern対象と起動失敗時のコンテキスト保持は成功しました。 +ADR-0023に従い、CliWrap起動だけを同期コンテキストnullで初期化し、finallyで +呼び出し元の状態を戻します。同期的な起動エラー・実行コンテキスト・公開API・ +依存・固定候補・公開設定は保持します。確認できた依存の欠陥を修正しますが、 +過去の全停止の原因とは断定しません。最終PR/main CIと成果物確認を必須とし、 +1.0判定と外部公開は保留します。 diff --git a/docs/roadmap.md b/docs/roadmap.md index 6e7d42e..1efc6bf 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -139,8 +139,14 @@ and CI 37115096538 (18 jobs, attempt 1), with independent artifact inspection. The remaining [Windows net48 completion investigation](phase-8-net48-investigation.md) verifies the diagnostic reader against an owned CLR4 dump and bounds stress to 30 rounds without claiming the historical intermittent stall is fixed. +Exact-main CI 37188258263 reproduced a stall and successfully retained actual +CLR4 state. PR #25 baseline CI 37188939390 confirms a deterministic legacy +startup SynchronizationContext defect; ADR-0023 isolates dependency startup while +preserving synchronous errors and caller context. Final PR/main gates are pending. 日本語: CP-05はPR #23と正確なmain CIの全18ジョブ成功・成果物確認で完了しました。 残るnet48停止は実ダンプでの解析器検証と上限付き反復で調査し、原因解決とは断定しません。 +mainでの再現と修正前CIから同期コンテキスト依存の欠陥を確認し、ADR-0023で +内部起動処理を修正します。最終PR/mainのCIと成果物確認は未完了です。 CP-04 bounded preview preparation completed through PR #22, main `846943fb739efdc30b4d5a6da469eef0d8855a98`, exact-main CI 37105452621 (all 16 jobs, diff --git a/eng/analyze_windows_hang.py b/eng/analyze_windows_hang.py index ac98357..8853b41 100644 --- a/eng/analyze_windows_hang.py +++ b/eng/analyze_windows_hang.py @@ -41,7 +41,7 @@ foreach (ClrObject obj in runtime.Heap.EnumerateObjects()) { string name = obj.Type?.Name ?? ""; - if (!(name.Contains("CliWrap.Command+") || name.Contains("ProcessSessionRunner+") || name.Contains("PsqlSession+") || name.Contains("PolyShim.") || name == "System.Diagnostics.Process")) continue; + if (!(name.Contains("CliWrap.Command+") || name.Contains("ProcessSessionRunner+") || name.Contains("PsqlSession+") || name.Contains("PolyShim.") || name.StartsWith("MemberPolyfills_") || name == "System.Diagnostics.Process")) continue; ClrInstanceField? state = obj.Type!.GetFieldByName("<>1__state"); if (state is not null && state.Read(obj.Address, false) == -2) continue; if (++count > 500) break; diff --git a/src/PgCliSharp/Internal/Execution/CliWrapExecution.cs b/src/PgCliSharp/Internal/Execution/CliWrapExecution.cs new file mode 100644 index 0000000..8cbb777 --- /dev/null +++ b/src/PgCliSharp/Internal/Execution/CliWrapExecution.cs @@ -0,0 +1,24 @@ +#if NETSTANDARD2_0 +using CliWrap; + +namespace PgCliSharp.Internal.Execution; + +internal static class CliWrapExecution +{ + internal static CommandTask Start(Command command, CancellationToken cancellationToken) + { + // The dependency's async enumeration otherwise captures the caller's context. + // Keep startup synchronous so start failures retain their existing semantics. + SynchronizationContext? context = SynchronizationContext.Current; + try + { + SynchronizationContext.SetSynchronizationContext(null); + return command.ExecuteAsync(cancellationToken); + } + finally + { + SynchronizationContext.SetSynchronizationContext(context); + } + } +} +#endif diff --git a/src/PgCliSharp/Internal/Execution/ProcessRunner.cs b/src/PgCliSharp/Internal/Execution/ProcessRunner.cs index d8ccf0d..bafe94a 100644 --- a/src/PgCliSharp/Internal/Execution/ProcessRunner.cs +++ b/src/PgCliSharp/Internal/Execution/ProcessRunner.cs @@ -79,7 +79,7 @@ request.StandardError is null CommandTask commandTask; try { - commandTask = command.ExecuteAsync(executionSource.Token); + commandTask = CliWrapExecution.Start(command, executionSource.Token); } catch (Exception exception) when ( exception is Win32Exception || diff --git a/src/PgCliSharp/Internal/Execution/ProcessSessionRunner.cs b/src/PgCliSharp/Internal/Execution/ProcessSessionRunner.cs index ed42d37..b580739 100644 --- a/src/PgCliSharp/Internal/Execution/ProcessSessionRunner.cs +++ b/src/PgCliSharp/Internal/Execution/ProcessSessionRunner.cs @@ -473,7 +473,7 @@ internal CliWrapProcessSession( CommandTask commandTask; try { - commandTask = command.ExecuteAsync(_forcefulCancellation.Token); + commandTask = CliWrapExecution.Start(command, _forcefulCancellation.Token); } catch (Exception exception) when ( exception is Win32Exception || From c3d53b4bb23f3f7ee00638ce677028d8d5531f4e Mon Sep 17 00:00:00 2001 From: ShutenOishi Date: Sun, 4 Oct 2026 17:36:08 +0900 Subject: [PATCH 3/4] Link the final legacy context validation receipt --- docs/phase-8-net48-investigation.md | 5 +++++ docs/roadmap.md | 8 ++++++-- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/docs/phase-8-net48-investigation.md b/docs/phase-8-net48-investigation.md index 1e90088..741ab85 100644 --- a/docs/phase-8-net48-investigation.md +++ b/docs/phase-8-net48-investigation.md @@ -47,6 +47,10 @@ Both finite and session runners use the same internal adapter. No Task.Run or wider ExecutionContext suppression is used. Final exact PR/main CI and artifact inspection remain the acceptance gate for this change. The known defect and the historical captured stall are distinguished; no claim covers every past timeout. +The current [PR #25 validation receipt](https://github.com/ShutenOishi/pgcli-sharp/pull/25) +records the exact final PR/main commits, CI runs and independently inspected +diagnostic artifacts, preserving failures and superseded revisions. This avoids +an untested follow-up source change solely to embed the CI's own source SHA. The prepared ClrMD reader had not previously compiled or read a dump in CI. Windows now first captures an owned net48 `wait` helper using `MiniDumpWriteDump` @@ -91,4 +95,5 @@ ADR-0023に従い、CliWrap起動だけを同期コンテキストnullで初期 呼び出し元の状態を戻します。同期的な起動エラー・実行コンテキスト・公開API・ 依存・固定候補・公開設定は保持します。確認できた依存の欠陥を修正しますが、 過去の全停止の原因とは断定しません。最終PR/main CIと成果物確認を必須とし、 +正確なcommit・run・成果物をPR #25の検証記録に保持します。 1.0判定と外部公開は保留します。 diff --git a/docs/roadmap.md b/docs/roadmap.md index 1efc6bf..164d344 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -142,11 +142,15 @@ verifies the diagnostic reader against an owned CLR4 dump and bounds stress to Exact-main CI 37188258263 reproduced a stall and successfully retained actual CLR4 state. PR #25 baseline CI 37188939390 confirms a deterministic legacy startup SynchronizationContext defect; ADR-0023 isolates dependency startup while -preserving synchronous errors and caller context. Final PR/main gates are pending. +preserving synchronous errors and caller context. The current +[PR #25 validation receipt](https://github.com/ShutenOishi/pgcli-sharp/pull/25) +records final PR/main all-18 CI and independent diagnostic artifact inspection; +these are required before the fix is considered complete. 日本語: CP-05はPR #23と正確なmain CIの全18ジョブ成功・成果物確認で完了しました。 残るnet48停止は実ダンプでの解析器検証と上限付き反復で調査し、原因解決とは断定しません。 mainでの再現と修正前CIから同期コンテキスト依存の欠陥を確認し、ADR-0023で -内部起動処理を修正します。最終PR/mainのCIと成果物確認は未完了です。 +内部起動処理を修正します。最終PR/mainの全18CIと成果物確認を完了条件とし、 +PR #25の検証記録に保持します。 CP-04 bounded preview preparation completed through PR #22, main `846943fb739efdc30b4d5a6da469eef0d8855a98`, exact-main CI 37105452621 (all 16 jobs, From 3859d4eb31fa71e0b19dc87d23e35c5dee72d9fe Mon Sep 17 00:00:00 2001 From: ShutenOishi Date: Sun, 4 Oct 2026 18:05:51 +0900 Subject: [PATCH 4/4] Decode nested async promises and verify CLR4 interior fields --- ...-legacy-startup-synchronization-context.md | 6 +++ docs/phase-8-net48-investigation.md | 25 +++++++++ docs/roadmap.md | 4 ++ eng/analyze_windows_hang.py | 53 +++++++++++++------ eng/test_analyze_windows_hang.py | 23 +++++++- tests/PgCliSharp.TestProcess/Program.cs | 26 +++++++++ 6 files changed, 119 insertions(+), 18 deletions(-) diff --git a/docs/adr/0023-isolate-legacy-startup-synchronization-context.md b/docs/adr/0023-isolate-legacy-startup-synchronization-context.md index 2074ce8..3cfe60b 100644 --- a/docs/adr/0023-isolate-legacy-startup-synchronization-context.md +++ b/docs/adr/0023-isolate-legacy-startup-synchronization-context.md @@ -53,6 +53,12 @@ it would exceed the evidence. A passing bounded run is regression evidence, not proof of absence of all intermittent process defects. 1.0 acceptance and postponed publication remain separate. +CI 37189457631 confirms the limitation: the context regressions pass, but net48 +stress round 27 still stalls with all four lower tasks complete and a yielded +WhenEach value awaiting consumer progress. Context isolation is a confirmed +partial fix; final merge gates remain blocked. Nested promise/awaiter diagnostics +are expanded before choosing any additional runtime/dependency change. + ## Primary-source evidence - [Pinned CliWrap execution](https://github.com/Tyrrrz/CliWrap/blob/3.10.5/CliWrap/Command.Execution.cs) diff --git a/docs/phase-8-net48-investigation.md b/docs/phase-8-net48-investigation.md index 741ab85..2aa85e8 100644 --- a/docs/phase-8-net48-investigation.md +++ b/docs/phase-8-net48-investigation.md @@ -52,6 +52,28 @@ records the exact final PR/main commits, CI runs and independently inspected diagnostic artifacts, preserving failures and superseded revisions. This avoids an untested follow-up source change solely to embed the CI's own source SHA. +The context-isolation revision `c3d53b4bb23f3f7ee00638ce677028d8d5531f4e` +passed the full Windows suites and 26 net48 stress rounds in CI 37189457631, +then stalled in round 27 on the 262144-byte case. Its controlled-context tests +pass, so the confirmed startup-context fix is not sufficient for the intermittent +defect. The PR remains Draft; this failed run is not retried or merged. +Artifact 11297554452, SHA-256 +`a4d56daa20274368b2f22c25f7fa4c246095ef6f0d2767ea4c176d543d07fded`, +was independently checked. Again, all four lower tasks are normally completed; +the outer ExecuteAsync is at state 2, while the internalized WhenEach iterator +is at state -4 with a completed stdout task as its current yielded value. + +Further diagnosis reads bounded nested value-type fields (awaiters and the +ManualResetValueTaskSourceCore promise), including short tokens, completion flags +and captured-context type references, with three-level nesting and a global +10,000-field limit. ExecutionContext fields are decoded only as scalar flags and +type/address references; string/environment contents are still never read. +Iterator state -2 is retained rather than treated as an ordinary completed async +method. This is an evidence improvement, not another claimed runtime fix. +The owned CLR4 smoke fixture now keeps a nested struct alive and requires its +actual int/short/bool values (1729/37/true) from the dump, so a decoder that cannot +read interior field addresses cannot silently pass the readiness gate. + The prepared ClrMD reader had not previously compiled or read a dump in CI. Windows now first captures an owned net48 `wait` helper using `MiniDumpWriteDump` and requires CLR4, thread and fixture-managed frames from the actual dump. @@ -97,3 +119,6 @@ ADR-0023に従い、CliWrap起動だけを同期コンテキストnullで初期 過去の全停止の原因とは断定しません。最終PR/main CIと成果物確認を必須とし、 正確なcommit・run・成果物をPR #25の検証記録に保持します。 1.0判定と外部公開は保留します。 +同期コンテキスト修正後も反復27回目で停止が再現し、PRはDraftのままです。 +awaiter/promiseの構造体内部と継続先の型を上限付きで採取し、専用CLR4子の +既知のint/short/boolを実ダンプから読めることも必須にします。 diff --git a/docs/roadmap.md b/docs/roadmap.md index 164d344..1dca50d 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -146,11 +146,15 @@ preserving synchronous errors and caller context. The current [PR #25 validation receipt](https://github.com/ShutenOishi/pgcli-sharp/pull/25) records final PR/main all-18 CI and independent diagnostic artifact inspection; these are required before the fix is considered complete. +The context regressions now pass, but CI 37189457631 stalls in net48 round 27; +the Draft PR's merge gate remains blocked while nested async diagnostics continue. 日本語: CP-05はPR #23と正確なmain CIの全18ジョブ成功・成果物確認で完了しました。 残るnet48停止は実ダンプでの解析器検証と上限付き反復で調査し、原因解決とは断定しません。 mainでの再現と修正前CIから同期コンテキスト依存の欠陥を確認し、ADR-0023で 内部起動処理を修正します。最終PR/mainの全18CIと成果物確認を完了条件とし、 PR #25の検証記録に保持します。 +同期コンテキストの回帰は成功しましたが、net48反復27回目で再び停止し、 +PRはDraftのまま追加診断を続けます。 CP-04 bounded preview preparation completed through PR #22, main `846943fb739efdc30b4d5a6da469eef0d8855a98`, exact-main CI 37105452621 (all 16 jobs, diff --git a/eng/analyze_windows_hang.py b/eng/analyze_windows_hang.py index 8853b41..dc25268 100644 --- a/eng/analyze_windows_hang.py +++ b/eng/analyze_windows_hang.py @@ -37,34 +37,51 @@ Console.WriteLine($" {frame}"); } // Only relevant async state/pipe fields; do not print environment, strings or arbitrary memory. - int count = 0; - foreach (ClrObject obj in runtime.Heap.EnumerateObjects()) + int fieldsRead = 0; + void Fields(ulong address, ClrType type, bool interior, int depth) { - string name = obj.Type?.Name ?? ""; - if (!(name.Contains("CliWrap.Command+") || name.Contains("ProcessSessionRunner+") || name.Contains("PsqlSession+") || name.Contains("PolyShim.") || name.StartsWith("MemberPolyfills_") || name == "System.Diagnostics.Process")) continue; - ClrInstanceField? state = obj.Type!.GetFieldByName("<>1__state"); - if (state is not null && state.Read(obj.Address, false) == -2) continue; - if (++count > 500) break; - Console.WriteLine($"OBJECT {obj.Address:x} {name}"); - foreach (ClrInstanceField field in obj.Type.Fields) + foreach (ClrInstanceField field in type.Fields.Take(40)) { + if (++fieldsRead > 10000) return; + string prefix = new string(' ', 2 + depth * 2); try { if (field.ElementType == ClrElementType.Int32) - Console.WriteLine($" {field.Name} = {field.Read(obj.Address, false)}"); + Console.WriteLine($"{prefix}{field.Name} = {field.Read(address, interior)}"); + else if (field.ElementType == ClrElementType.Int16) + Console.WriteLine($"{prefix}{field.Name} = {field.Read(address, interior)}"); else if (field.ElementType == ClrElementType.Boolean) - Console.WriteLine($" {field.Name} = {field.Read(obj.Address, false)}"); + Console.WriteLine($"{prefix}{field.Name} = {field.Read(address, interior)}"); else if (field.IsObjectReference) { - ClrObject value = field.ReadObject(obj.Address, false); - Console.WriteLine($" {field.Name} = {value.Address:x} {value.Type?.Name}"); + ClrObject value = field.ReadObject(address, interior); + Console.WriteLine($"{prefix}{field.Name} = {value.Address:x} {value.Type?.Name}"); ClrInstanceField? flags = value.Type?.GetFieldByName("m_stateFlags"); - if (flags is not null) Console.WriteLine($" taskFlags = {flags.Read(value.Address, false):x}"); + if (flags is not null) Console.WriteLine($"{prefix} taskFlags = {flags.Read(value.Address, false):x}"); + if (depth < 3 && value.Type?.Name == "System.Threading.ExecutionContext") + Fields(value.Address, value.Type, false, depth + 1); + } + else if (field.ElementType == ClrElementType.Struct && depth < 3) + { + ClrValueType value = field.ReadStruct(address, interior); + Console.WriteLine($"{prefix}{field.Name} STRUCT {value.Type?.Name}"); + if (value.Type is not null) Fields(value.Address, value.Type, true, depth + 1); } } - catch (Exception e) { Console.WriteLine($" {field.Name}: {e.GetType().Name}"); } + catch (Exception e) { Console.WriteLine($"{prefix}{field.Name}: {e.GetType().Name}"); } } } + int count = 0; + foreach (ClrObject obj in runtime.Heap.EnumerateObjects()) + { + string name = obj.Type?.Name ?? ""; + if (!(name.Contains("CliWrap.Command+") || name.Contains("ProcessSessionRunner+") || name.Contains("PsqlSession+") || name.Contains("PolyShim.") || name.StartsWith("MemberPolyfills_") || name == "System.Diagnostics.Process" || name == "StackReaderSmokeFixture")) continue; + ClrInstanceField? state = obj.Type!.GetFieldByName("<>1__state"); + if (!name.StartsWith("MemberPolyfills_") && state is not null && state.Read(obj.Address, false) == -2) continue; + if (++count > 500) break; + Console.WriteLine($"OBJECT {obj.Address:x} {name}"); + Fields(obj.Address, obj.Type!, false, 0); + } } internal static class Native { @@ -80,12 +97,12 @@ def smoke(reader, folder, helper): trace = Path(folder) / "stack-reader-smoke.child.log" dump = Path(folder) / "stack-reader-smoke.dmp" trace.unlink(missing_ok=True) - child = subprocess.Popen([str(Path(helper).resolve()), "wait"], stdin=subprocess.DEVNULL, + child = subprocess.Popen([str(Path(helper).resolve()), "diagnostic-wait"], stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, env={**os.environ, "PGCLI_TEST_TRACE_FILE": str(trace.resolve())}) try: deadline = time.monotonic() + 10 - while not trace.exists() or "ready" not in trace.read_text(encoding="utf-8"): + while not trace.exists() or "diagnostic-ready" not in trace.read_text(encoding="utf-8"): if child.poll() is not None or time.monotonic() >= deadline: raise RuntimeError("Owned CLR4 smoke child did not become ready.") time.sleep(0.05) @@ -96,6 +113,8 @@ def smoke(reader, folder, helper): stacks = log.read_text(encoding="utf-8") if "CLR 4." not in stacks or "THREAD " not in stacks or "Program." not in stacks: raise RuntimeError("Owned CLR4 smoke dump did not yield expected managed frames.") + if not all(marker in stacks for marker in ("Marker = 1729", "Token = 37", "Completed = True")): + raise RuntimeError("Owned CLR4 smoke dump did not yield expected nested fields.") finally: if child.poll() is None: child.kill() diff --git a/eng/test_analyze_windows_hang.py b/eng/test_analyze_windows_hang.py index 475153d..e745aa3 100644 --- a/eng/test_analyze_windows_hang.py +++ b/eng/test_analyze_windows_hang.py @@ -28,7 +28,7 @@ def test_invalid_smoke_stacks_fail_and_clean_up_owned_child(self): child.poll.return_value = None def start(*args, **kwargs): - Path(kwargs["env"]["PGCLI_TEST_TRACE_FILE"]).write_text("ready", encoding="utf-8") + Path(kwargs["env"]["PGCLI_TEST_TRACE_FILE"]).write_text("diagnostic-ready", encoding="utf-8") return child def run(command, **kwargs): @@ -44,6 +44,27 @@ def run(command, **kwargs): child.wait.assert_called_once_with(timeout=10) self.assertFalse((Path(root) / "stack-reader-smoke.dmp").exists()) + def test_smoke_cannot_pass_with_managed_frames_but_missing_nested_fields(self): + with tempfile.TemporaryDirectory() as root: + child = MagicMock(pid=12345) + child.poll.return_value = None + + def start(*args, **kwargs): + Path(kwargs["env"]["PGCLI_TEST_TRACE_FILE"]).write_text("diagnostic-ready", encoding="utf-8") + return child + + def run(command, **kwargs): + if "--capture" in command: + Path(command[-1]).write_bytes(b"private-test-memory") + else: + kwargs["stdout"].write("CLR 4.0\nTHREAD 1\nProgram.Main\n") + + with patch.object(analyzer.subprocess, "Popen", side_effect=start), patch.object(analyzer.subprocess, "run", side_effect=run): + with self.assertRaisesRegex(RuntimeError, "expected nested fields"): + analyzer.smoke(Path(root) / "reader.dll", root, Path(root) / "owned.exe") + child.kill.assert_called_once() + self.assertFalse((Path(root) / "stack-reader-smoke.dmp").exists()) + if __name__ == "__main__": unittest.main() diff --git a/tests/PgCliSharp.TestProcess/Program.cs b/tests/PgCliSharp.TestProcess/Program.cs index 4620891..5d78e39 100644 --- a/tests/PgCliSharp.TestProcess/Program.cs +++ b/tests/PgCliSharp.TestProcess/Program.cs @@ -29,6 +29,14 @@ void Record(string stage) Thread.Sleep(Timeout.Infinite); return; } +if (args.Length == 1 && args[0] == "diagnostic-wait") +{ + var fixture = new StackReaderSmokeFixture(); + Record("diagnostic-ready"); + Thread.Sleep(Timeout.Infinite); + GC.KeepAlive(fixture); + return; +} using Stream input = Console.OpenStandardInput(); using Stream output = Console.OpenStandardOutput(); Record("copy-enter"); @@ -38,3 +46,21 @@ void Record(string stage) output.Flush(); Console.Error.WriteLine("drained"); Record("drained"); + +// Kept alive in the owned smoke process to verify nested CLR4 field addresses. +internal sealed class StackReaderSmokeFixture +{ + internal SmokePromise Promise = new() { Marker = 1729, Awaiter = new() { Token = 37, Completed = true } }; +} + +internal struct SmokePromise +{ + internal int Marker; + internal SmokeAwaiter Awaiter; +} + +internal struct SmokeAwaiter +{ + internal short Token; + internal bool Completed; +}