diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6f9a645..7b09638 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -62,6 +62,8 @@ jobs: python3 -m unittest discover -s eng -p test_candidate_preflight.py python3 -m unittest discover -s eng -p test_native_postgresql.py python3 -m unittest discover -s eng -p test_analyze_windows_hang.py + python3 -m unittest discover -s eng -p test_development_dependencies.py + python3 -m unittest discover -s eng -p test_windows_stress.py python3 eng/validate_candidate.py - name: Restore @@ -86,15 +88,7 @@ jobs: env: PGCLI_TEST_TRACE_DIRECTORY: ${{ github.workspace }}/test-results/process-stages if: runner.os == 'Windows' - shell: pwsh - run: | - foreach ($tfm in @('net48', 'net8.0', 'net10.0')) { - $rounds = if ($tfm -eq 'net48') { 30 } else { 3 } - 1..$rounds | ForEach-Object { - dotnet test tests/PgCliSharp.Tests/PgCliSharp.Tests.csproj -c Release -f $tfm --no-build --no-restore --filter 'FullyQualifiedName~PsqlCompletionTests|FullyQualifiedName~ProcessSessionRunnerTests' --logger trx --results-directory "test-results/stress-$tfm-$_" --blame-hang-timeout 2m --blame-hang-dump-type full --diag "test-results/stress-$tfm-$_/vstest.log" - if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - } - } + run: python eng/run_windows_stress.py test-results - name: Read Windows hang stacks and remove intermediate dumps if: always() && runner.os == 'Windows' @@ -155,6 +149,7 @@ jobs: artifacts/*.nupkg artifacts/*.snupkg artifacts/DEVELOPMENT-PACKAGE-PROVENANCE.txt + artifacts/DEVELOPMENT-DEPENDENCIES.json if-no-files-found: error release-preflight: diff --git a/AGENTS.md b/AGENTS.md index 091c2ce..4ead812 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -26,7 +26,7 @@ If an Accepted decision changes, create a new superseding ADR instead of rewriti - Prefer enums for finite option sets and dedicated value objects for structured values. - Do not expose arbitrary command-line strings as the primary API when a typed representation is practical. - Validate option availability and incompatible combinations before starting the process. -- Use `ProcessStartInfo.ArgumentList` on modern targets. The `netstandard2.0` compatibility backend uses CliWrap according to ADR-0008. Do not execute PostgreSQL tools through `cmd.exe`, PowerShell, `bash -c`, or another shell. +- Use `ProcessStartInfo.ArgumentList` on modern targets. The `netstandard2.0` compatibility backend shares the Process lifecycle with attributed native argument serialization, race-safe exit events and best-effort legacy Windows tree termination according to ADR-0024. Do not execute PostgreSQL tools through `cmd.exe`, PowerShell, `bash -c`, or another shell. - Keep the core package free of unnecessary runtime dependencies. - Public APIs must have bilingual English/Japanese XML documentation according to `docs/localization.md`. - Human-facing project surfaces must provide Japanese as well as English. This includes the repository README, GitHub Release titles/notes, user-oriented guides, and other prominent public documentation. The root README may link prominently to a maintained `README.ja.md` instead of duplicating the full text inline. diff --git a/README.ja.md b/README.ja.md index 497fefe..048630f 100644 --- a/README.ja.md +++ b/README.ja.md @@ -244,7 +244,7 @@ dotnet test PgCliSharp.slnx --configuration Release --no-build --no-restore ターゲットフレームワークは `netstandard2.0;net8.0;net10.0` です。 -- `netstandard2.0`: ADR-0008 に基づき、内部のプロセス実行互換層として CliWrap を使用します。 +- `netstandard2.0`: ADR-0024に基づき、Processの終了監視を共有し、内部で引数整形・終了通知を補います。System.ManagementでWindows子孫終了を試み、CliWrap依存は外します。子孫終了APIのない古いUnixランタイムでは直接の子のみ停止できます。固定alpha.2候補は旧実装のままです。 - `net8.0` / `net10.0`: .NET BCL のプロセス API を直接使用します。 ## ライセンス diff --git a/README.md b/README.md index bc45574..f637f01 100644 --- a/README.md +++ b/README.md @@ -242,7 +242,7 @@ dotnet build PgCliSharp.slnx --configuration Release --no-restore dotnet test PgCliSharp.slnx --configuration Release --no-build --no-restore ``` -The `netstandard2.0` process backend uses CliWrap internally according to ADR-0008. Modern targets use the .NET BCL process APIs directly. +All targets use the .NET BCL Process lifecycle. Under ADR-0024, `netstandard2.0` adds internal argument/exit compatibility and uses System.Management for best-effort Windows descendant termination; CliWrap is no longer a dependency. Modern targets use ArgumentList and native asynchronous exit/tree APIs. Older Unix runtimes without tree termination support can terminate only the immediate child. The preserved alpha.2 candidate still contains its original backend. ## License diff --git a/THIRD-PARTY-NOTICES.md b/THIRD-PARTY-NOTICES.md index a7cb22b..ae0a540 100644 --- a/THIRD-PARTY-NOTICES.md +++ b/THIRD-PARTY-NOTICES.md @@ -6,25 +6,20 @@ PgCliSharp packages contain PgCliSharp assemblies and documentation, not PostgreSQL, libpq, .NET runtime binaries or dependency assemblies. NuGet resolves the following dependencies separately for netstandard2.0. This inventory records the reviewed lock; applications must preserve notices for their actual resolution -and redistributed files. Modern net8.0/net10.0 assets have no CliWrap dependency. +and redistributed files. Modern net8.0/net10.0 assets have no execution runtime dependencies. This inventory +applies to the ADR-0024 development source; frozen alpha.2 retains its own notices +and lock at its immutable source. | Package | Reviewed version | License | Package copyright | |---|---|---|---| -| CliWrap | 3.10.5 | MIT | Copyright (C) Oleksii Holub | -| Microsoft.Bcl.AsyncInterfaces | 10.0.8 | MIT | © Microsoft Corporation. All rights reserved. | -| System.Buffers | 4.6.1 | MIT | © Microsoft Corporation. All rights reserved. | | System.CodeDom | 10.0.10 | MIT | © Microsoft Corporation. All rights reserved. | | System.Management | 10.0.10 | MIT | © Microsoft Corporation. All rights reserved. | -| System.Memory | 4.6.3 | MIT | © Microsoft Corporation. All rights reserved. | -| System.Numerics.Vectors | 4.6.1 | MIT | © Microsoft Corporation. All rights reserved. | -| System.Runtime.CompilerServices.Unsafe | 6.1.2 | MIT | © Microsoft Corporation. All rights reserved. | -| System.Threading.Tasks.Extensions | 4.6.3 | MIT | © Microsoft Corporation. All rights reserved. | The expressions and copyright strings are from the exact NuGet packages. The standard MIT permission/disclaimer below accompanies these notices; it does not replace an upstream package's own files or grant additional rights. The .NET packages also retain attribution to the .NET Foundation and Contributors. -Microsoft.Bcl.AsyncInterfaces, System.CodeDom and System.Management supply the same +System.CodeDom and System.Management supply the same upstream [additional notices](docs/third-party/dotnet-notices.txt), preserved byte for byte (SHA256 `6d15e10a101c6bfff2ab4429ed061bf76c456fc4b23ad6b03e0d0f8377148a21`). Keeping that entire upstream notice does not assert all its components are used. @@ -38,6 +33,16 @@ obtaining/using or redistributing them. The candidate audit retains the original text/hash and rejects unexpected shipped files. This review covers the PgCliSharp nupkg/snupkg boundary, not every downstream or self-contained application. +### Attributed native argument serialization + +ProcessCompatibility.QuoteArgument adapts the .NET Foundation PasteArguments +algorithm, replacing its internal buffer with StringBuilder. Source: +https://github.com/dotnet/runtime/blob/9a50493f9f1125fda5e2212b9d6718bc7cdbc5c0/src/libraries/System.Private.CoreLib/src/System/PasteArguments.cs + +Copyright (c) .NET Foundation and Contributors. Licensed under MIT. The following +permission and disclaimer apply to this adapted source as well as the MIT +packages listed above. No CliWrap/PolyShim implementation is vendored. + ### MIT permission text Permission is hereby granted, free of charge, to any person obtaining a copy @@ -67,3 +72,7 @@ PgCliSharpは外部CLI・libpq・.NETランタイム・依存DLLを同梱しま 古いMicrosoft独自ライセンスの参照パッケージはMITと読み替えず、原文・hashを監査記録へ残します。 今回の配布物にそれらが入らないことを確認し、その範囲で配布境界のレビューを完了します。 別のアプリや.NET/PostgreSQLを同梱する配布物の条件までは代行しません。 + +引数整形は.NET FoundationのPasteArgumentsをStringBuilder向けに改変したMITコードです。 +上記の著作権・許諾文を保持します。CliWrap/PolyShimのコードは取り込みません。 +固定alpha.2の旧依存・表示はimmutable sourceに保持し、新しい開発ソースと区別します。 diff --git a/docs/adr/0008-netstandard20-cliwrap-process-backend.md b/docs/adr/0008-netstandard20-cliwrap-process-backend.md index 50a17a1..bdfdbd2 100644 --- a/docs/adr/0008-netstandard20-cliwrap-process-backend.md +++ b/docs/adr/0008-netstandard20-cliwrap-process-backend.md @@ -1,10 +1,10 @@ # ADR-0008: Use a conditional CliWrap backend for .NET Standard 2.0 process execution -- Status: Accepted +- Status: Superseded - Date: 2026-09-18 - Decision owners: PgCliSharp maintainers - Supersedes: ADR-0005 -- Superseded by: None +- Superseded by: ADR-0024 ## Context 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..386ea92 --- /dev/null +++ b/docs/adr/0023-isolate-legacy-startup-synchronization-context.md @@ -0,0 +1,68 @@ +# ADR-0023: Isolate the legacy backend's startup synchronization context + +- Status: Superseded +- Date: 2026-10-04 +- Superseded by: ADR-0024 +- 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. + +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) +- [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/0024-share-process-backend-with-legacy-compatibility.md b/docs/adr/0024-share-process-backend-with-legacy-compatibility.md new file mode 100644 index 0000000..6dfdad7 --- /dev/null +++ b/docs/adr/0024-share-process-backend-with-legacy-compatibility.md @@ -0,0 +1,87 @@ +# ADR-0024: Share the Process backend with narrow legacy compatibility + +- Status: Accepted +- Date: 2026-10-04 +- Related: ADR-0008, ADR-0013, ADR-0014, ADR-0023 +- Supersedes: ADR-0008, ADR-0023 + +## Context + +The exact-main CLR4 capture and the context-isolated PR #25 capture both show +CliWrap's four lower tasks normally completed while its async enumeration and +outer command remain pending. The deterministic startup-context defect is real, +but isolating startup does not resolve the intermittent stall. Failed runs and +artifact digests are preserved in the [investigation](../phase-8-net48-investigation.md). +Nested-diagnostic CI 37191060046 again aborts net48 in the initial full suite. +Its actual CLR4 promise version and consumer token both equal 3, with iterator +state -4 and all four lower tasks complete. Pinned PolyShim's promise uses lazy +unsynchronized TCS initialization, which introduces a potential race; the capture +does not prove the continuation graph or establish this as the root cause. +The backend replacement removes that iterator/promise transition entirely. + +The maintainer authorizes consideration of replacing CliWrap if necessary. The +target matrix, public API, binary I/O, caller stream ownership, cancellation and +timeout contracts remain requirements; removing netstandard2.0 is not proposed. + +## Alternatives + +| Approach | Benefit | Cost / acceptance condition | +|---|---|---| +| Fix the pinned dependency upstream | Retains upstream process maintenance | A demonstrated fix must cover both the deterministic context defect and the captured completion stall; a newer fixed release is currently unavailable | +| Vendor/fork CliWrap and change its async loop | Can remove the observed iterator transition | Carries an entire process library and upstream patch/license maintenance; changing that loop alone is not proof of a root fix | +| Share the existing Process backend, add narrow compatibility | Removes the suspect command/iterator completion path and several legacy dependencies | Own argument serialization, race-safe exit waiting and legacy tree termination must receive explicit review and runtime tests | +| Keep the current adapter and repeat until green | Small code change | Rejected: it already reproduced the stall and a later pass would not explain it | + +## Decision + +- Reuse the existing finite/session Process lifecycle and its explicit awaits, + fault supervision, original deadline through output draining, and bounded + abnormal task observation. +- Keep modern ArgumentList, WaitForExitAsync and Kill(entireProcessTree: true). +- For netstandard2.0, serialize argument tokens using the .NET Foundation's MIT + PasteArguments rules with attribution and execution tests for empty values, + quotes, Unicode, whitespace and trailing backslashes. Never add a shell. +- Wait for legacy exit with an asynchronously continuing TaskCompletionSource, + subscribe before checking HasExited, and detach the event handler after exit. +- Retain Windows best-effort descendant discovery using System.Management + 10.0.10, already present transitively, instead of adding job-object/native + launch machinery. Document unsupported legacy platform limits honestly. +- Retain the 64 KiB / 16-segment legacy input bridge, feed it directly to stdin, + and deliver EOF only after accepted bytes drain. Exit/cancel/disposal must + release blocked producers without disposing caller streams. +- Preserve the former legacy finite-command EOF when no stdin source is supplied. + Modern finite commands keep their existing inherited-input behavior. Sessions + always redirect stdin on every target and require explicit EOF unless the child + exits first. A dedicated net48 regression verifies the legacy no-source case. +- Remove CliWrap and its now-unneeded async-iterator dependencies from new + package assets. Do not retarget frozen alpha.2 or alter its dependency lock. + +## Acceptance gates + +Build all three TFMs; exercise netstandard2.0 on Windows net48. Controlled-context +regressions, binary equality/EOF, early exit, nonzero status, stderr/environment, +caller-stream faults, cancellation/timeout and descendant termination must pass. +Run bounded Windows 30/3/3 stress without retrying failures, review actual TRX and +stage artifacts, and require fresh full PR CI plus exact-main CI. Native PostgreSQL +3-OS and Linux 10–18 evidence remain required. Inspect the new dependency graph +and notices separately from the preserved preview candidate. + +This decision replaces the legacy completion path; it is not proof that every +historical stall shares one cause. A replacement changes the owned lifecycle path; its +evidence must establish maintained behavior rather than merely a passing retry. +External publication stays postponed. + +## Primary sources + +- [Pinned CliWrap execution](https://github.com/Tyrrrz/CliWrap/blob/45819e3bc44d9be4d778e91a0ec5cf6a5176de56/CliWrap/Command.Execution.cs) +- [Pinned PolyShim legacy tree termination](https://github.com/Tyrrrz/PolyShim/blob/2.12.2/PolyShim/NetCore30/Process.cs) +- [.NET argument serialization](https://github.com/dotnet/runtime/blob/9a50493f9f1125fda5e2212b9d6718bc7cdbc5c0/src/libraries/System.Private.CoreLib/src/System/PasteArguments.cs) + +## 日本語 + +CliWrapの下位4Taskが完了した後も上位が保留される停止が、同期コンテキスト修正後も +再現しました。必要なら依存を置き換える方針を検討します。既存のProcess実装を共有し、 +古いAPIに不足する引数整形・終了通知・Windows子孫終了だけを補う案を優先します。 +対応TFM・公開API・バイナリI/O・入力上限・呼出元ストリームの所有権・期限を維持します。 +互換処理の保守責任は本プロジェクトへ移るため、実行テスト・依存と権利表示の確認・ +最終PRとmainのCIを移行条件とします。移行の最終PR/main検証が済むまで完了扱いにはせず、alpha.2と公開延期は保持します。 diff --git a/docs/adr/README.md b/docs/adr/README.md index df1c438..5fc7305 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -65,7 +65,7 @@ Do not silently edit an old Accepted ADR to make history appear different. | [0005](0005-direct-process-execution-and-stream-safe-output.md) | Execute tools directly and preserve stream-safe output | Superseded | | [0006](0006-nuget-release-and-trusted-publishing.md) | Publish NuGet packages through automated trusted releases | Superseded | | [0007](0007-target-framework-matrix.md) | Initial target framework matrix | Accepted | -| [0008](0008-netstandard20-cliwrap-process-backend.md) | Use a conditional CliWrap backend for .NET Standard 2.0 process execution | Accepted | +| [0008](0008-netstandard20-cliwrap-process-backend.md) | Use a conditional CliWrap backend for .NET Standard 2.0 process execution | Superseded | | [0009](0009-phase-release-artifacts.md) | Record every completed phase as a GitHub Release with immutable artifacts | Superseded | | [0010](0010-bilingual-human-facing-releases.md) | Provide bilingual human-facing documentation and Phase Releases | Superseded | | [0011](0011-specification-first-tool-implementation.md) | Use a specification-first workflow for PostgreSQL CLI tools | Accepted | @@ -80,6 +80,9 @@ 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 | Superseded | + +| [0024](0024-share-process-backend-with-legacy-compatibility.md) | Share the Process backend with narrow legacy compatibility | Accepted | ## Template diff --git a/docs/architecture.md b/docs/architecture.md index 5b34ffe..c720f76 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -2,7 +2,7 @@ > This document is the consolidated current-state architecture. Decision rationale and historical changes are recorded in [Architecture Decision Records](adr/README.md). If an Accepted decision is replaced, preserve the old ADR and supersede it with a new ADR. -Key accepted decisions currently include ADR-0001 through ADR-0004, ADR-0007 through ADR-0008, and ADR-0011 through ADR-0018. ADR-0005 has been superseded by ADR-0008; ADR-0006 and ADR-0010 have been superseded by ADR-0012. +Key accepted decisions currently include ADR-0001 through ADR-0004, ADR-0007, and ADR-0011 through ADR-0018 plus ADR-0024. ADR-0005 was superseded by ADR-0008; ADR-0008 and ADR-0023 are superseded by ADR-0024; ADR-0006 and ADR-0010 have been superseded by ADR-0012. ## 1. Project purpose @@ -131,20 +131,20 @@ Validation is part of the product, not merely test code. Execution uses target-specific internal backends while preserving one PgCliSharp behavior model: - `net8.0` and `net10.0` use `System.Diagnostics.Process` directly; -- `netstandard2.0` uses CliWrap 3.10.5 internally as a compatibility backend; -- CliWrap is referenced only by the `netstandard2.0` package asset and its types do not appear in the public PgCliSharp API. +- `netstandard2.0` shares the Process lifecycle with narrow argument/exit compatibility under ADR-0024; +- only the legacy asset references System.Management 10.0.10 for best-effort Windows descendant termination; CliWrap is removed from new assets. Requirements: - no shell mediation; -- pass individual argument values; modern targets use `ProcessStartInfo.ArgumentList`, while the `netstandard2.0` backend delegates token formatting to CliWrap; +- pass individual argument values; modern targets use `ProcessStartInfo.ArgumentList`, while the `netstandard2.0` backend uses attributed .NET native argument serialization; - redirect stdout/stderr where needed; - support binary stdout without converting the entire stream to text; - support binary-safe stdin streaming for tools/options that consume standard input, including PostgreSQL 17+ `pg_dump --filter=-`, `pg_restore` archive/filter stdin, and `pg_dumpall --filter=-`; - support `CancellationToken`; - support timeout configuration; - attempt to terminate the complete process tree on cancellation/timeout where the target framework supports it; -- supervise modern stdout/stderr transfer faults as lifecycle outcomes rather than waiting only for process exit; +- supervise stdout/stderr transfer faults on every target as lifecycle outcomes rather than waiting only for process exit; - keep the configured cancellation/timeout deadline active while redirected output drains after process exit; - bound abnormal cleanup after a best-effort termination attempt rather than waiting indefinitely for process exit or a caller-owned stream; - never dispose caller-owned streams or claim that arbitrary cancellation-noncooperative stream implementations can be forcibly stopped; @@ -152,7 +152,16 @@ 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. -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. +Under ADR-0024, all internal awaits use explicit ConfigureAwait(false), and legacy +exit waiting subscribes to Exited before checking HasExited. There is no command +async iterator or dependency-startup context adapter. Caller context is not +mutated; controlled-context regressions remain required. On legacy Windows, +System.Management discovers descendants for best-effort termination. Modern +runtimes retain the native tree API, including netstandard assets loaded on a +runtime that exposes it. Older Unix runtimes without that API fall back to the +immediate child, as the former dependency did; no tree guarantee is claimed. + +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/0024 make session output faults part of lifecycle supervision on all targets and bound the `netstandard2.0` 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 @@ -245,11 +254,11 @@ Phase 6 extends the specification-first contract to `psql` and `pgbench`, both m Finite psql execution uses the tool-specific `PsqlIo` model for optional caller-owned stdin/stdout/stderr. Command and file actions share one ordered collection because upstream permits `--command` and `--file` to repeat and interleave. Variable assignments preserve unset versus empty values, and version-specific output features such as PostgreSQL 12+ CSV are validated before process startup. psql's documented exit statuses 0-3 are returned as `PsqlExitStatus` values. -ADR-0013 adds a separate internal long-lived redirected-process lifecycle rather than weakening the one-shot `IProcessRunner`. `PsqlSession` exposes writable stdin after process start, explicit EOF, streamed stdout/stderr, cancellation, timeout/process-tree termination, and asynchronous completion metadata. The modern .NET implementation uses `System.Diagnostics.Process`; `netstandard2.0` uses CliWrap internally while preserving the same PgCliSharp-owned abstraction. Redirected sessions are explicitly pipe based and are not TTY/PTY emulation. +ADR-0013 adds a separate internal long-lived redirected-process lifecycle rather than weakening the one-shot `IProcessRunner`. `PsqlSession` exposes writable stdin after process start, explicit EOF, streamed stdout/stderr, cancellation, timeout/process-tree termination, and asynchronous completion metadata. All targets share `System.Diagnostics.Process` lifecycle supervision, with narrow legacy compatibility under ADR-0024. Redirected sessions are explicitly pipe based and are not TTY/PTY emulation. pgbench remains finite execution and uses `PgBenchIo` for caller-owned stdout/stderr; no public stdin contract is exposed because the audited PostgreSQL 10-18 CLI has no stdin workload interface. The wrapper models PostgreSQL 11/13/15/17 option changes, PostgreSQL 13+ server-side initialization step `G`, the report option rename, the PostgreSQL 17 `-d` reassignment while emitting stable `--debug`, script weights and script-count limits, initialization-versus-benchmark mode restrictions, logging/progress/partition/retry constraints, and the historical exit-status boundary where runtime status 2 is defined from PostgreSQL 12. -Phase 6 tests cover spec-to-API/runtime availability, deterministic serialization, semantic exit statuses, caller-owned stderr streaming, executable-version mismatch, and the long-lived session lifecycle. Real process-session tests verify writes after startup, explicit EOF, timeout, and cancellation. Windows executes the suite through .NET Framework 4.8 as well as modern targets, exercising the `netstandard2.0`/CliWrap session backend. +Phase 6 tests cover spec-to-API/runtime availability, deterministic serialization, semantic exit statuses, caller-owned stderr streaming, executable-version mismatch, and the long-lived session lifecycle. Real process-session tests verify writes after startup, explicit EOF, timeout, and cancellation. Windows executes the suite through .NET Framework 4.8 as well as modern targets, exercising the `netstandard2.0` compatibility session backend. Phase 7 separates server applications into `PgCliSharp.ServerApplications` under ADR-0015. Six dedicated Options APIs retain centralized option availability and @@ -274,7 +283,7 @@ Use three logical test layers: Phase 8 CP-02 established pinned official-source PostgreSQL 10-18 Linux builds under historical ADR-0016. ADR-0022 retains that evidence and adds required native Windows/macOS PostgreSQL 18/net10.0 representative execution as CP-05. It requires actual source/binary/server versions, hashes, owned data directories and unique Passed TRX evidence. CI and independent artifact inspection must succeed before reporting this checkpoint complete. Native older versions/other TFMs, service lifecycle, TLS/ICU/TTY/compression and additional migration/destructive scenarios remain outside this scope. See `docs/phase-8-native-os.md` and `docs/integration-testing.md`. This checkpoint does not promote 1.0 or authorize publication. -Windows CI should also execute tests through a .NET Framework consumer target so the `netstandard2.0` package asset and CliWrap compatibility backend run in-process. CI should cover PostgreSQL 10-18 as far as reproducibly possible. Legacy versions may need isolated/containerized test environments. +Windows CI should also execute tests through a .NET Framework consumer target so the `netstandard2.0` package asset and compatibility backend run in-process. CI should cover PostgreSQL 10-18 as far as reproducibly possible. Legacy versions may need isolated/containerized test environments. ## 14. Package and target framework policy @@ -295,7 +304,7 @@ Initial target-framework matrix: Phase 0 validation confirmed this matrix across Linux, macOS, and Windows CI. Windows also executes a .NET Framework 4.8 test target against the `netstandard2.0` library asset so the legacy compatibility backend is exercised at runtime. Future changes to this accepted matrix require the ADR superseding workflow. -The core package should avoid unnecessary dependencies such as Npgsql or Microsoft.Extensions packages unless a clear project-wide benefit justifies them. ADR-0008 allows CliWrap specifically and only for the `netstandard2.0` execution backend because the .NET Standard 2.0 BCL does not provide equivalent argument/process-tree APIs. +The core package should avoid unnecessary dependencies such as Npgsql or Microsoft.Extensions packages unless a clear project-wide benefit justifies them. ADR-0024 removes CliWrap from new assets and permits System.Management only for legacy Windows descendant discovery. Modern assets keep no execution runtime dependencies. The preserved alpha.2 candidate retains its original CliWrap dependency lock and source. ## 15. NuGet and release policy diff --git a/docs/licensing.md b/docs/licensing.md index 5b93568..e7f2d27 100644 --- a/docs/licensing.md +++ b/docs/licensing.md @@ -19,14 +19,14 @@ redistribute PostgreSQL binaries/source, retain its copyright/license text and audit any additional components in the particular distribution. Calling an external CLI is not a substitute for a distribution's third-party obligations. -The netstandard2.0 asset references [CliWrap 3.10.5](https://www.nuget.org/packages/CliWrap/3.10.5), -whose package declares MIT and Copyright (C) Oleksii Holub. It is resolved as a -separate NuGet dependency, not embedded or relicensed as PgCliSharp code. Modern -net8.0/net10.0 assets do not reference CliWrap. Preserve upstream notices whenever -redistributing dependencies. SourceLink and test tools are build/test dependencies, -not shipped runtime dependencies. The reviewed runtime dependency inventory, -copyright/permission text and upstream .NET notices are included in -[THIRD-PARTY-NOTICES.md](../THIRD-PARTY-NOTICES.md). +New netstandard2.0 assets reference System.Management 10.0.10 for best-effort +Windows descendant discovery under ADR-0024; CliWrap is removed. Modern assets +have no execution runtime dependencies. The reviewed legacy runtime inventory +and .NET additional notices remain in [THIRD-PARTY-NOTICES.md](../THIRD-PARTY-NOTICES.md). +Native argument serialization adapts .NET Foundation MIT source with retained +attribution and permission text. No CliWrap/PolyShim source is vendored. Frozen +alpha.2 still contains its original CliWrap backend and independently pinned +notices/dependency lock. SourceLink and tests remain build/test dependencies. The preserved Phase 3 artifacts/source are not rewritten. Adopting MIT does not enable publication or claim Phase 8 completion. The separate [unpublished @@ -49,11 +49,11 @@ PostgreSQLは独自のPostgreSQL Licenseのままで、MITへ変更するもの PostgreSQL本体を再配布する場合は、その著作権表示・ライセンス文と、対象配布物に 含まれる第三者コンポーネントの条件を別途保持・確認してください。 -netstandard2.0のみCliWrap 3.10.5へ依存します。同パッケージの表示はMIT、 -著作権者はOleksii Holubです。NuGetが別途解決する依存であり、PgCliSharpへ埋め込んで -権利表示を置き換えません。依存物を再配布する際は元の表示を保持してください。 -推移的な実行時依存の一覧・著作権・許諾文と.NET追加表示を -[THIRD-PARTY-NOTICES.md](../THIRD-PARTY-NOTICES.md)へ保存し、同梱します。 +新しいnetstandard2.0資産はWindows子孫終了用System.Management 10.0.10に依存し、 +CliWrap依存を外します。modern対象には実行用の依存を追加しません。.NET Foundationの +MIT引数整形を改変し、出典・著作権・許諾文を保持します。CliWrap/PolyShimを取り込まず、 +固定alpha.2は旧実装・依存lock・表示を元のソースに保持します。現在の依存一覧と表示は +[THIRD-PARTY-NOTICES.md](../THIRD-PARTY-NOTICES.md)へ保存して同梱します。 旧Phase 3の履歴は書き換えず、公開設定は無効のままです。 [未公開候補監査](phase-8-candidate-audit.md)は確定した推移的依存と原文を記録します。 古いMicrosoft独自ライセンスは原文・hashを残し、参照パッケージ・依存DLL・runtime.jsonを diff --git a/docs/phase-8-net48-investigation.md b/docs/phase-8-net48-investigation.md index ddbc290..f07ad9a 100644 --- a/docs/phase-8-net48-investigation.md +++ b/docs/phase-8-net48-investigation.md @@ -1,5 +1,66 @@ # Windows net48 completion investigation / 完了待ち調査 +## Replacement follow-up / 実行基盤の置換 + +Replacement final-candidate CI 37192663590 at +`2a1b77d0f9320f34addc5dcbb086f97957aa77e8` passes all normal full suites, +native Windows/macOS PostgreSQL and the corrected clean-consumer package audit. +However, its Windows stress step remains reported in progress beyond its +10-minute step cap, with no downloadable job log or Windows artifact at the +observation point. This is unresolved runner/command-lifecycle evidence, not a +proved recurrence of the library stall and not a successful completion receipt. +The earlier replacement CI 37192226343 completed all 30/3/3 rounds but failed its +initial dependency-audit expectation; neither incomplete run clears the merge gate. + +The next diagnostic revision independently bounds each owned dotnet test command +to 180 seconds, including host/collector shutdown, records UTC/PID/TFM/round, +elapsed duration and actual exit in stress-commands.jsonl, and stops on the first +nonzero/timeout outcome. Timeout terminates only the newly started invocation's +tree and is recorded as failure 124. The original session 15-second deadlines, +testcase two-minute blame watchdog, total 10-minute step cap and 30/3/3 counts +remain. No local VSTest execution or browser fallback is used. Existing workflow +concurrency supersedes the unresponsive run when this meaningful diagnostic +revision is published; old run outcomes remain separate evidence. + +置換案の最終候補は通常テスト・両native・依存監査を通過しましたが、Windows反復stepが +10分上限後も実行中表示で、ログ/Windows成果物を取得できません。ライブラリ再停止とは +断定せず、マージを保留します。新しい診断では各自所有のdotnet testコマンドの終了を +180秒で制限し、PID・対象・回数・所要時間・実終了値を記録します。期限切れは失敗とし、 +再試行せず止めます。既存の15秒/2分/10分期限と反復回数は変えません。 + +Nested-diagnostic CI 37191060046 at source +`3859d4eb31fa71e0b19dc87d23e35c5dee72d9fe` reproduces a five-byte stall in the +initial net48 full suite (44 completed, aborted before stress); modern suites +pass 554 each. Actual owned CLR4 smoke succeeds with int 1729, short 37, bool true. +Artifact 11298503924 was independently checked against SHA-256 +`3a7916571d76ea7fb82626dfc318ffeb919e208b0825cd68b9772ab578e76ae3` and contains no DMP. +The active promise version and consumer token both equal 3; WhenEach state is -4, +ExecuteAsync state is 2, and all four lower tasks have flags 0x7000400. The promise +is PolyShim's internal lazy-TCS compatibility implementation. Its unsynchronized +TCS initialization is a potential race in pinned primary source, not a root cause +proven by this capture; the TCS continuation graph was not decoded. + +The maintainer authorizes considering replacement. [ADR-0024](adr/0024-share-process-backend-with-legacy-compatibility.md) +adopts a shared Process lifecycle, removes the dependency's iterator/promise path, +and preserves explicit tokens, binary streams, original deadlines and the 1 MiB +input bridge. Legacy exit waiting subscribes before checking HasExited; Windows +descendant termination retains the already-reviewed System.Management version. +New tests cover exact troublesome tokens/environment, already-exited processes, +nonzero status/stderr and early session exit releasing blocked input. Existing +stream-fault/noncooperative-output tests now also run on net48. The actual new +development dependency graph is audited separately from frozen alpha.2. + +Final replacement PR and exact-main CI, Windows stress/actual artifacts and all +three-OS/native evidence are pending; no successful retry is claimed as a fix. +PR #25 retains the failed partial-fix evidence. Publication remains postponed. + +追加診断でも通常net48テストが停止しました。promiseと消費側tokenは一致し、下位4Taskは +正常完了ですが上位は保留です。互換promiseの遅延TCS初期化に競合の可能性を確認しましたが、 +継続グラフは未取得で原因の断定はしません。ADR-0024で既存Process実装を共有して依存の +列挙経路を外し、引数・終了通知・Windows子孫終了だけを補います。入力上限・EOF・所有権・ +期限を保持し、net48のI/O障害、実引数、即時終了と入力待ち解除を検証します。 +最終PRと正確なmainのCI/成果物確認前には1.0完了と扱わず、公開延期を保持します。 + ## English CP-05 is complete through [PR #23](https://github.com/ShutenOishi/pgcli-sharp/pull/23): @@ -17,6 +78,63 @@ 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. 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 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 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. @@ -34,8 +152,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. ## 日本語 @@ -50,5 +170,16 @@ 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と成果物確認を必須とし、 +正確な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 6e7d42e..b885262 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -18,7 +18,7 @@ Build a strongly typed .NET API for PostgreSQL command-line tools with explicit - Add localization resources (English neutral + Japanese). - Implement common exceptions/diagnostics. - Implement process runner abstraction. -- Use the BCL process backend on modern .NET and a conditional CliWrap backend on `netstandard2.0`. +- Foundation originally used the BCL backend on modern .NET and CliWrap on `netstandard2.0`; ADR-0024 replaces that legacy backend during 1.0 stabilization. - Implement cancellation and timeout behavior. - Implement executable version parser/cache. - Add PostgreSQL 10-18 version metadata. @@ -110,7 +110,7 @@ External NuGet/GitHub Release publication remains deferred under ADR-0012. Phase - `pgbench` — PostgreSQL 10-18, including PostgreSQL 11/13/15/17 option changes, PostgreSQL 13+ server-side initialization step `G`, PostgreSQL 12+ runtime exit status 2, script-weight semantics, and upstream-determinable mode/logging/progress/partition/retry validation. - `PsqlSession` exposes writable stdin after process startup, explicit EOF, cancellation, timeout/process-tree termination, caller-owned stdout/stderr streaming, and completion metadata. - Redirected psql sessions are pipe based and explicitly are not TTY/PTY emulation. -- The internal session lifecycle is separate from the established one-shot process runner. Modern .NET uses `System.Diagnostics.Process`; the `netstandard2.0` path uses CliWrap without leaking dependency types. +- The internal session lifecycle is separate from the established one-shot process runner. All targets now share `System.Diagnostics.Process` lifecycle supervision with narrow legacy compatibility under ADR-0024 and no public dependency types. - Windows CI exercises the `netstandard2.0` surface through .NET Framework 4.8 in addition to Linux/macOS modern targets. The maintained inventories are `spec/postgresql/psql.json` and `spec/postgresql/pgbench.json`. Research and implementation notes are in `docs/rich-io-phase-6.md`, the lifecycle decision is ADR-0013, and completion evidence is recorded in `docs/phase-6-completion.md`. @@ -139,8 +139,20 @@ 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. PR #25's ADR-0023 startup isolation passes +those regressions but CI 37189457631 stalls in round 27, and nested-diagnostic CI +37191060046 stalls in the initial suite. The partial fix is superseded by ADR-0024's +shared Process replacement in [PR #26](https://github.com/ShutenOishi/pgcli-sharp/pull/26). +Fresh final PR/main all-18 CI and independent artifact inspection are required +before replacement completion; PR #25 preserves the failed evidence. 日本語: CP-05はPR #23と正確なmain CIの全18ジョブ成功・成果物確認で完了しました。 残るnet48停止は実ダンプでの解析器検証と上限付き反復で調査し、原因解決とは断定しません。 +mainでの再現と修正前CIから同期コンテキスト依存の欠陥を確認しましたが、 +PR #25の修正後も反復27回目と追加診断の通常テストで停止しました。 +ADR-0024とPR #26でProcess共有実装へ置換し、最終PR/mainの全18CIと +成果物確認を完了条件とします。PR #25は失敗の検証記録として保持します。 CP-04 bounded preview preparation completed through PR #22, main `846943fb739efdc30b4d5a6da469eef0d8855a98`, exact-main CI 37105452621 (all 16 jobs, @@ -213,3 +225,18 @@ For every phase: New PostgreSQL major versions should be added by extending version metadata, compatibility specifications, typed options, and tests without rewriting older-version support. Pre-release PostgreSQL versions should not automatically be advertised as stable supported versions. + +### Legacy backend replacement / 旧互換実行基盤の置換 + +ADR-0024 supersedes ADR-0008/0023. CliWrap context isolation passed the controlled +regressions but failed bounded stress and a subsequent initial full suite. New +assets share the existing Process lifecycle, retain bounded input/EOF and add +native argument/exit compatibility plus the already-reviewed System.Management +Windows tree dependency. Fresh final PR and exact-main CI and actual artifacts +are still mandatory; implementation alone does not clear the open 1.0 gate. +Frozen alpha.2 and postponed publication remain unchanged. + +同期コンテキスト修正だけでは停止が再現したため、ADR-0024でCliWrap依存を外し、 +既存Processの終了監視を共有します。入力上限・EOF・公開APIを保持し、旧APIに不足する +引数整形・終了通知・Windows子孫終了を補います。最終PR/mainのCI・成果物確認は未完で、 +1.0完了と固定alpha.2の差し替えを意味しません。公開は後回しのままです。 diff --git a/eng/analyze_windows_hang.py b/eng/analyze_windows_hang.py index ac98357..c912973 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 == "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("ProcessRunner+") || name.Contains("ProcessCompatibility+") || 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/audit_development_dependencies.py b/eng/audit_development_dependencies.py new file mode 100644 index 0000000..79499ce --- /dev/null +++ b/eng/audit_development_dependencies.py @@ -0,0 +1,25 @@ +"""Audit new development assets independently of the frozen publication candidate.""" +import json +from pathlib import Path +import sys +from audit_candidate import audit_dependencies + +EXPECTED = { + "System.Management/10.0.10", "System.CodeDom/10.0.10", +} + + +def audit(consumer, output): + reports = {} + for tfm in ("netstandard2.0", "net8.0", "net10.0"): + records = audit_dependencies(Path(consumer) / tfm / "obj/project.assets.json") + runtime = {record["package"] for record in records + if record["runtime_frameworks"] and not record["package"].startswith("PgCliSharp/")} + assert runtime == (EXPECTED if tfm == "netstandard2.0" else set()), (tfm, runtime) + reports[tfm] = records + Path(output).write_text(json.dumps({"publication_candidate": False, "dependencies": reports}, + ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + + +if __name__ == "__main__": + audit(sys.argv[1], sys.argv[2]) diff --git a/eng/run_windows_stress.py b/eng/run_windows_stress.py new file mode 100644 index 0000000..fe39fa8 --- /dev/null +++ b/eng/run_windows_stress.py @@ -0,0 +1,61 @@ +"""Bound each owned VSTest invocation, including host/collector shutdown.""" +import json +import os +from pathlib import Path +import subprocess +import sys +import time +from datetime import datetime, timezone + + +def record(folder, **fields): + with (Path(folder) / "stress-commands.jsonl").open("a", encoding="utf-8") as output: + output.write(json.dumps({"utc": datetime.now(timezone.utc).isoformat(), **fields}) + "\n") + + +def one_round(folder, tfm, number): + result_folder = Path(folder) / f"stress-{tfm}-{number}" + result_folder.mkdir(parents=True, exist_ok=True) + record(folder, event="starting", tfm=tfm, round=number) + command = ["dotnet", "test", "tests/PgCliSharp.Tests/PgCliSharp.Tests.csproj", "-c", "Release", "-f", tfm, + "--no-build", "--no-restore", "--filter", "FullyQualifiedName~PsqlCompletionTests|FullyQualifiedName~ProcessSessionRunnerTests", + "--logger", "trx", "--results-directory", str(result_folder), "--blame-hang-timeout", "2m", + "--blame-hang-dump-type", "full", "--diag", str(result_folder / "vstest.log")] + started = time.monotonic() + process = subprocess.Popen(command) + record(folder, event="started", tfm=tfm, round=number, pid=process.pid) + try: + result = process.wait(timeout=180) + except subprocess.TimeoutExpired: + # Only the invocation started here and its descendants are targeted. + # Session deadlines and the existing testcase blame timeout are unchanged. + record(folder, event="command-timeout", tfm=tfm, round=number, pid=process.pid) + try: + if process.poll() is None: + killer = Path(os.environ["SystemRoot"]) / "System32/taskkill.exe" + subprocess.run([str(killer), "/PID", str(process.pid), "/T", "/F"], + check=False, timeout=20, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + finally: + if process.poll() is None: + process.kill() + process.wait(timeout=10) + result = 124 + record(folder, event="finished", tfm=tfm, round=number, pid=process.pid, + exitCode=result, seconds=round(time.monotonic() - started, 3)) + return result + + +def run(folder): + Path(folder).mkdir(parents=True, exist_ok=True) + for tfm, rounds in (("net48", 30), ("net8.0", 3), ("net10.0", 3)): + for number in range(1, rounds + 1): + result = one_round(folder, tfm, number) + if result != 0: + return result + return 0 + + +if __name__ == "__main__": + if os.name != "nt": + raise SystemExit("Windows stress must run on Windows.") + sys.exit(run(sys.argv[1])) 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/eng/test_development_dependencies.py b/eng/test_development_dependencies.py new file mode 100644 index 0000000..7427021 --- /dev/null +++ b/eng/test_development_dependencies.py @@ -0,0 +1,35 @@ +import json +from pathlib import Path +import tempfile +import unittest +from unittest.mock import patch +import audit_development_dependencies as audit + + +class DevelopmentDependencyTests(unittest.TestCase): + def test_rejects_retained_cliwrap_runtime(self): + records = [{"package": name, "runtime_frameworks": ["legacy"]} for name in audit.EXPECTED] + records.append({"package": "CliWrap/3.10.5", "runtime_frameworks": ["legacy"]}) + with patch.object(audit, "audit_dependencies", return_value=records): + with self.assertRaises(AssertionError): + audit.audit("consumer", "unused.json") + + def test_rejects_execution_dependency_in_modern_asset(self): + legacy = [{"package": name, "runtime_frameworks": ["legacy"]} for name in audit.EXPECTED] + modern = [{"package": "System.Management/10.0.10", "runtime_frameworks": ["modern"]}] + with patch.object(audit, "audit_dependencies", side_effect=[legacy, modern]): + with self.assertRaises(AssertionError): + audit.audit("consumer", "unused.json") + + def test_ignores_reference_only_packages_and_marks_nonpublication(self): + legacy = [{"package": name, "runtime_frameworks": ["legacy"]} for name in audit.EXPECTED] + reference = {"package": "NETStandard.Library/2.0.3", "runtime_frameworks": []} + with tempfile.TemporaryDirectory() as folder: + output = Path(folder) / "audit.json" + with patch.object(audit, "audit_dependencies", side_effect=[legacy + [reference], [], []]): + audit.audit("consumer", output) + self.assertIs(json.loads(output.read_text())["publication_candidate"], False) + + +if __name__ == "__main__": + unittest.main() diff --git a/eng/test_windows_stress.py b/eng/test_windows_stress.py new file mode 100644 index 0000000..9f714db --- /dev/null +++ b/eng/test_windows_stress.py @@ -0,0 +1,41 @@ +import json +from pathlib import Path +import subprocess +import tempfile +import unittest +from unittest.mock import Mock, patch +import run_windows_stress as stress + + +class WindowsStressTests(unittest.TestCase): + def test_command_timeout_targets_only_started_pid_and_is_a_failure(self): + process = Mock(pid=1729) + process.wait.side_effect = [subprocess.TimeoutExpired("owned", 180), 1] + process.poll.return_value = None + with tempfile.TemporaryDirectory() as folder, patch.object(stress.subprocess, "Popen", return_value=process), \ + patch.object(stress.subprocess, "run") as terminate, patch.dict(stress.os.environ, {"SystemRoot": "fixture-root"}): + self.assertEqual(124, stress.one_round(folder, "net48", 1)) + self.assertEqual(terminate.call_args.args[0][1:], ["/PID", "1729", "/T", "/F"]) + process.kill.assert_called_once() + records = [json.loads(line) for line in (Path(folder) / "stress-commands.jsonl").read_text().splitlines()] + self.assertEqual(["starting", "started", "command-timeout", "finished"], [r["event"] for r in records]) + self.assertEqual(124, records[-1]["exitCode"]) + + def test_nonzero_command_stops_before_any_retry_or_next_round(self): + with tempfile.TemporaryDirectory() as folder, patch.object(stress, "one_round", side_effect=[0, 1]) as execute: + self.assertEqual(1, stress.run(folder)) + self.assertEqual(2, execute.call_count) + self.assertEqual([1, 2], [c.args[2] for c in execute.call_args_list]) + + def test_successful_command_retains_actual_exit_and_elapsed_record(self): + process = Mock(pid=37) + process.wait.return_value = 0 + with tempfile.TemporaryDirectory() as folder, patch.object(stress.subprocess, "Popen", return_value=process): + self.assertEqual(0, stress.one_round(folder, "net48", 1)) + records = [json.loads(line) for line in (Path(folder) / "stress-commands.jsonl").read_text().splitlines()] + self.assertEqual(0, records[-1]["exitCode"]) + process.kill.assert_not_called() + + +if __name__ == "__main__": + unittest.main() diff --git a/eng/verify-nuget-package.sh b/eng/verify-nuget-package.sh index d34f739..84e15bd 100755 --- a/eng/verify-nuget-package.sh +++ b/eng/verify-nuget-package.sh @@ -81,4 +81,6 @@ EOF dotnet build "$dir/Smoke.csproj" --configuration Release --no-restore done +python3 eng/audit_development_dependencies.py "$consumer" "$artifacts_dir/DEVELOPMENT-DEPENDENCIES.json" + echo "Verified $nupkg and $snupkg" diff --git a/src/PgCliSharp/Internal/Execution/ProcessCompatibility.cs b/src/PgCliSharp/Internal/Execution/ProcessCompatibility.cs new file mode 100644 index 0000000..d659ea3 --- /dev/null +++ b/src/PgCliSharp/Internal/Execution/ProcessCompatibility.cs @@ -0,0 +1,141 @@ +using System.ComponentModel; +using System.Diagnostics; +#if NETSTANDARD2_0 +using System.Management; +using System.Runtime.InteropServices; +using System.Text; +#endif + +namespace PgCliSharp.Internal.Execution; + +internal static class ProcessCompatibility +{ + internal static void SetArguments(ProcessStartInfo startInfo, IEnumerable arguments) + { +#if NETSTANDARD2_0 + startInfo.Arguments = string.Join(" ", arguments.Select(QuoteArgument)); +#else + foreach (string argument in arguments) startInfo.ArgumentList.Add(argument); +#endif + } + + internal static Task WaitForExitAsync(Process process) + { +#if NETSTANDARD2_0 + return WaitForLegacyExitAsync(process); +#else + return process.WaitForExitAsync(CancellationToken.None); +#endif + } + + internal static void TryTerminateProcessTree(Process process) + { + try + { + if (process.HasExited) return; +#if NETSTANDARD2_0 + // A netstandard asset can also run on a runtime with the modern API. + var killTree = typeof(Process).GetMethod("Kill", new[] { typeof(bool) }); + if (killTree is not null) + { + killTree.Invoke(process, new object[] { true }); + return; + } + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + var visited = new HashSet(); + KillLegacyWindowsTree(process.Id, visited); + } + else + { + // Same legacy Unix limitation as the former dependency's polyfill. + process.Kill(); + } +#else + process.Kill(entireProcessTree: true); +#endif + } + catch (Exception) + { + // Tree discovery/permissions can fail. Still attempt the owned child. + try { if (!process.HasExited) process.Kill(); } + catch (Exception) { } + } + } + +#if NETSTANDARD2_0 + private static async Task WaitForLegacyExitAsync(Process process) + { + var exited = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + EventHandler handler = (_, _) => exited.TrySetResult(true); + process.Exited += handler; + try + { + process.EnableRaisingEvents = true; + // Subscribe before this check: exit between checking and subscription + // must never strand the completion task. + if (process.HasExited) exited.TrySetResult(true); + await exited.Task.ConfigureAwait(false); + } + finally + { + process.Exited -= handler; + } + } + + private static void KillLegacyWindowsTree(int processId, HashSet visited) + { + if (!visited.Add(processId)) return; + var descendants = new List(); + using (var searcher = new ManagementObjectSearcher( + "SELECT ProcessID FROM Win32_Process WHERE ParentProcessID=" + + processId.ToString(System.Globalization.CultureInfo.InvariantCulture))) + using (ManagementObjectCollection results = searcher.Get()) + { + foreach (ManagementObject child in results) + { + using (child) descendants.Add(Convert.ToInt32(child["ProcessID"], System.Globalization.CultureInfo.InvariantCulture)); + } + } + try + { + using (Process parent = Process.GetProcessById(processId)) + if (!parent.HasExited) parent.Kill(); + } + catch (Exception exception) when (exception is ArgumentException || exception is InvalidOperationException || exception is Win32Exception) + { + // A process can exit between discovery and termination. + } + foreach (int childId in descendants) + { + try { KillLegacyWindowsTree(childId, visited); } + catch (Exception) { /* Best effort per branch; continue other descendants. */ } + } + } + + // Adapted from .NET Foundation PasteArguments (MIT); see THIRD-PARTY-NOTICES. + // This is native argument serialization, never shell quoting. + private static string QuoteArgument(string argument) + { + if (argument.Length > 0 && argument.All(c => !char.IsWhiteSpace(c) && c != '"')) + return argument; + var buffer = new StringBuilder(); + buffer.Append('"'); + for (int index = 0; index < argument.Length;) + { + char value = argument[index++]; + if (value == '\\') + { + int count = 1; + while (index < argument.Length && argument[index] == '\\') { index++; count++; } + if (index == argument.Length) buffer.Append('\\', count * 2); + else if (argument[index] == '"') { buffer.Append('\\', count * 2 + 1).Append('"'); index++; } + else buffer.Append('\\', count); + } + else if (value == '"') buffer.Append('\\').Append('"'); + else buffer.Append(value); + } + return buffer.Append('"').ToString(); + } +#endif +} diff --git a/src/PgCliSharp/Internal/Execution/ProcessRunner.cs b/src/PgCliSharp/Internal/Execution/ProcessRunner.cs index d8ccf0d..ea92eae 100644 --- a/src/PgCliSharp/Internal/Execution/ProcessRunner.cs +++ b/src/PgCliSharp/Internal/Execution/ProcessRunner.cs @@ -1,10 +1,6 @@ using System.ComponentModel; using System.Diagnostics; using System.Runtime.ExceptionServices; -#if NETSTANDARD2_0 -using System.Text; -using CliWrap; -#endif namespace PgCliSharp.Internal.Execution; @@ -25,103 +21,9 @@ public Task RunAsync( cancellationToken.ThrowIfCancellationRequested(); -#if NETSTANDARD2_0 - return RunWithCliWrapAsync(request, cancellationToken); -#else return RunWithProcessAsync(request, cancellationToken); -#endif } -#if NETSTANDARD2_0 - private static async Task RunWithCliWrapAsync( - ProcessRunRequest request, - CancellationToken cancellationToken) - { - var standardError = request.StandardError is null ? new StringBuilder() : null; - Stream standardOutput = request.StandardOutput ?? Stream.Null; - - Command command = Cli.Wrap(request.ExecutablePath) - .WithArguments(request.Arguments) - .WithValidation(CommandResultValidation.None) - .WithStandardOutputPipe(PipeTarget.ToStream(standardOutput)) - .WithStandardErrorPipe( - request.StandardError is null - ? PipeTarget.ToStringBuilder(standardError!) - : PipeTarget.ToStream(request.StandardError)); - - if (request.StandardInput is not null) - { - command = command.WithStandardInputPipe( - PipeSource.FromStream(request.StandardInput)); - } - - if (request.EnvironmentVariables is not null) - { - command = command.WithEnvironmentVariables( - builder => - { - foreach (KeyValuePair pair in request.EnvironmentVariables) - { - builder.Set(pair.Key, pair.Value); - } - }); - } - - using var timeoutSource = request.Timeout.HasValue - ? new CancellationTokenSource(request.Timeout.Value) - : null; - using var executionSource = timeoutSource is null - ? CancellationTokenSource.CreateLinkedTokenSource(cancellationToken) - : CancellationTokenSource.CreateLinkedTokenSource( - cancellationToken, - timeoutSource.Token); - - CommandTask commandTask; - try - { - commandTask = command.ExecuteAsync(executionSource.Token); - } - catch (Exception exception) when ( - exception is Win32Exception || - exception is InvalidOperationException) - { - throw new PgExecutableStartException(request.ExecutablePath, exception); - } - - var stopwatch = Stopwatch.StartNew(); - - CommandResult commandResult; - try - { - commandResult = await commandTask.ConfigureAwait(false); - } - catch (OperationCanceledException) when (executionSource.IsCancellationRequested) - { - stopwatch.Stop(); - - cancellationToken.ThrowIfCancellationRequested(); - - if (timeoutSource is not null && timeoutSource.IsCancellationRequested) - { - throw new PgProcessTimeoutException( - request.ExecutablePath, - request.Timeout!.Value); - } - - throw; - } - - stopwatch.Stop(); - - var result = new ProcessRunResult( - commandResult.ExitCode, - stopwatch.Elapsed, - standardError?.ToString() ?? string.Empty); - - ThrowForNonZeroExitCodeIfRequested(request, result); - return result; - } -#else private static readonly TimeSpan AbnormalCleanupGracePeriod = TimeSpan.FromSeconds(2); @@ -150,6 +52,10 @@ exception is Win32Exception || } var stopwatch = Stopwatch.StartNew(); +#if NETSTANDARD2_0 + // The former backend supplied EOF when no input source was configured. + if (request.StandardInput is null) TryCloseStandardInput(process); +#endif using var outputCancellation = new CancellationTokenSource(); Task standardErrorTask = ReadOrStreamStandardErrorAsync( @@ -176,7 +82,7 @@ exception is Win32Exception || process.StandardInput, inputCancellation.Token); - Task waitTask = process.WaitForExitAsync(CancellationToken.None); + Task waitTask = ProcessCompatibility.WaitForExitAsync(process); Task cancellationTask = cancellationToken.CanBeCanceled ? Task.Delay(Timeout.Infinite, cancellationToken) : Task.Delay(Timeout.Infinite, CancellationToken.None); @@ -356,8 +262,12 @@ private static async Task ReadOrStreamStandardErrorAsync( { if (destination is null) { + #if NETSTANDARD2_0 + return await process.StandardError.ReadToEndAsync().ConfigureAwait(false); +#else return await process.StandardError.ReadToEndAsync( cancellationToken).ConfigureAwait(false); +#endif } await process.StandardError.BaseStream.CopyToAsync( @@ -410,13 +320,14 @@ private static ProcessStartInfo CreateStartInfo(ProcessRunRequest request) CreateNoWindow = true, RedirectStandardError = true, RedirectStandardOutput = true, +#if NETSTANDARD2_0 + RedirectStandardInput = true, +#else RedirectStandardInput = request.StandardInput is not null, +#endif }; - foreach (string argument in request.Arguments) - { - startInfo.ArgumentList.Add(argument); - } + ProcessCompatibility.SetArguments(startInfo, request.Arguments); if (request.EnvironmentVariables is not null) { @@ -442,24 +353,8 @@ exception is InvalidOperationException || } } - private static void TryTerminateProcessTree(Process process) - { - try - { - if (!process.HasExited) - { - process.Kill(entireProcessTree: true); - } - } - catch (Exception exception) when ( - exception is InvalidOperationException || - exception is Win32Exception || - exception is NotSupportedException) - { - // Best effort: cancellation/timeout/I/O failure still reports the primary outcome. - } - } -#endif + private static void TryTerminateProcessTree(Process process) => + ProcessCompatibility.TryTerminateProcessTree(process); private static void ThrowForNonZeroExitCodeIfRequested( ProcessRunRequest request, diff --git a/src/PgCliSharp/Internal/Execution/ProcessSessionRunner.cs b/src/PgCliSharp/Internal/Execution/ProcessSessionRunner.cs index ed42d37..53bf65e 100644 --- a/src/PgCliSharp/Internal/Execution/ProcessSessionRunner.cs +++ b/src/PgCliSharp/Internal/Execution/ProcessSessionRunner.cs @@ -2,9 +2,6 @@ using System.ComponentModel; using System.Diagnostics; using System.Runtime.ExceptionServices; -#if NETSTANDARD2_0 -using CliWrap; -#endif namespace PgCliSharp.Internal.Execution; @@ -90,45 +87,10 @@ public IProcessSession Start( #endif cancellationToken.ThrowIfCancellationRequested(); -#if NETSTANDARD2_0 - return StartWithCliWrap(request, cancellationToken); -#else return StartWithProcess(request, cancellationToken); -#endif } -#if NETSTANDARD2_0 - private static CliWrapProcessSession StartWithCliWrap( - ProcessSessionStartRequest request, - CancellationToken cancellationToken) - { - var inputPipe = new SessionInputPipe(); - - Command command = Cli.Wrap(request.ExecutablePath) - .WithArguments(request.Arguments) - .WithValidation(CommandResultValidation.None) - .WithStandardInputPipe(inputPipe.Source) - .WithStandardOutputPipe(PipeTarget.ToStream(request.StandardOutput)) - .WithStandardErrorPipe(PipeTarget.ToStream(request.StandardError)); - - if (request.EnvironmentVariables is not null) - { - command = command.WithEnvironmentVariables( - builder => - { - foreach (KeyValuePair pair in request.EnvironmentVariables) - builder.Set(pair.Key, pair.Value); - }); - } - - return new CliWrapProcessSession( - request, - command, - inputPipe, - cancellationToken); - } -#else - private static ModernProcessSession StartWithProcess( + private static NativeProcessSession StartWithProcess( ProcessSessionStartRequest request, CancellationToken cancellationToken) { @@ -142,8 +104,7 @@ private static ModernProcessSession StartWithProcess( RedirectStandardError = true, }; - foreach (string argument in request.Arguments) - startInfo.ArgumentList.Add(argument); + ProcessCompatibility.SetArguments(startInfo, request.Arguments); if (request.EnvironmentVariables is not null) { @@ -166,10 +127,10 @@ exception is Win32Exception || throw new PgExecutableStartException(request.ExecutablePath, exception); } - return new ModernProcessSession(request, process, cancellationToken); + return new NativeProcessSession(request, process, cancellationToken); } - private sealed class ModernProcessSession : IProcessSession + private sealed class NativeProcessSession : IProcessSession { private static readonly TimeSpan AbnormalCleanupGracePeriod = TimeSpan.FromSeconds(2); @@ -180,19 +141,30 @@ private sealed class ModernProcessSession : IProcessSession new CancellationTokenSource(); private readonly CancellationTokenSource _ioCancellation = new CancellationTokenSource(); +#if NETSTANDARD2_0 + private readonly SessionInputPipe _inputPipe = new SessionInputPipe(); + private readonly CancellationTokenSource _inputCancellation = new CancellationTokenSource(); +#endif + private readonly Task _standardInputTask; private readonly Task _standardOutputTask; private readonly Task _standardErrorTask; private int _inputCompleted; private int _disposed; - internal ModernProcessSession( + internal NativeProcessSession( ProcessSessionStartRequest request, Process process, CancellationToken cancellationToken) { _request = request; _process = process; +#if NETSTANDARD2_0 + StandardInput = _inputPipe.Writer; + _standardInputTask = PumpInputAsync(); +#else StandardInput = process.StandardInput.BaseStream; + _standardInputTask = Task.CompletedTask; +#endif _standardOutputTask = process.StandardOutput.BaseStream.CopyToAsync( request.StandardOutput, 81920, @@ -213,6 +185,9 @@ public void CompleteInput() if (Interlocked.Exchange(ref _inputCompleted, 1) != 0) return; +#if NETSTANDARD2_0 + _inputPipe.Complete(); +#else try { _process.StandardInput.Close(); @@ -223,7 +198,26 @@ exception is ObjectDisposedException || exception is IOException) { } +#endif + } + +#if NETSTANDARD2_0 + private async Task PumpInputAsync() + { + try + { + await _inputPipe.PumpAsync(_process.StandardInput.BaseStream, _inputCancellation.Token) + .ConfigureAwait(false); + _process.StandardInput.Close(); + } + catch (Exception exception) when ((_inputCancellation.IsCancellationRequested || _process.HasExited) && + (exception is OperationCanceledException || exception is IOException || exception is ObjectDisposedException)) + { + // Exit/cancellation releases blocked writers; accepted input need not + // be consumed by a process that has already ended. + } } +#endif public void Cancel() { @@ -254,7 +248,7 @@ private async Task CompleteAsync( _manualCancellation.Token); var stopwatch = Stopwatch.StartNew(); - Task waitTask = _process.WaitForExitAsync(CancellationToken.None); + Task waitTask = ProcessCompatibility.WaitForExitAsync(_process); Task cancellationTask = lifetimeCancellation.Token.CanBeCanceled ? Task.Delay(Timeout.Infinite, lifetimeCancellation.Token) : Task.Delay(Timeout.Infinite, CancellationToken.None); @@ -263,7 +257,8 @@ private async Task CompleteAsync( : Task.Delay(Timeout.Infinite, CancellationToken.None); Task ioFaultTask = WaitForIoFaultAsync( _standardOutputTask, - _standardErrorTask); + _standardErrorTask, + _standardInputTask); try { @@ -283,11 +278,16 @@ private async Task CompleteAsync( TryTerminateProcessTree(_process); CompleteInput(); _ioCancellation.Cancel(); +#if NETSTANDARD2_0 + _inputCancellation.Cancel(); + _inputPipe.Dispose(); +#endif await AwaitBoundedCleanupAsync( waitTask, _standardOutputTask, - _standardErrorTask) + _standardErrorTask, + _standardInputTask) .ConfigureAwait(false); stopwatch.Stop(); @@ -313,9 +313,14 @@ await AwaitBoundedCleanupAsync( "Process session ended without a process, timeout, cancellation, or I/O outcome."); } +#if NETSTANDARD2_0 + _inputCancellation.Cancel(); + _inputPipe.Dispose(); +#endif Task drainTask = Task.WhenAll( _standardOutputTask, - _standardErrorTask); + _standardErrorTask, + _standardInputTask); completedTask = await Task.WhenAny( drainTask, @@ -331,7 +336,8 @@ await AwaitBoundedCleanupAsync( await AwaitBoundedCleanupAsync( drainTask, _standardOutputTask, - _standardErrorTask) + _standardErrorTask, + _standardInputTask) .ConfigureAwait(false); stopwatch.Stop(); @@ -356,6 +362,11 @@ await AwaitBoundedCleanupAsync( finally { _ioCancellation.Cancel(); +#if NETSTANDARD2_0 + _inputCancellation.Cancel(); + _inputPipe.Dispose(); + _inputCancellation.Dispose(); +#endif _ioCancellation.Dispose(); _manualCancellation.Dispose(); _process.Dispose(); @@ -419,142 +430,11 @@ private static async Task ObserveCleanupTaskAsync(Task task) } } - private static void TryTerminateProcessTree(Process process) - { - try - { - if (!process.HasExited) - process.Kill(entireProcessTree: true); - } - catch (Exception exception) when ( - exception is InvalidOperationException || - exception is Win32Exception || - exception is NotSupportedException) - { - // Best effort. Completion still reports the primary outcome. - } - } + private static void TryTerminateProcessTree(Process process) => + ProcessCompatibility.TryTerminateProcessTree(process); } -#endif #if NETSTANDARD2_0 - private sealed class CliWrapProcessSession : IProcessSession - { - private readonly ProcessSessionStartRequest _request; - private readonly SessionInputPipe _inputPipe; - private readonly CancellationTokenSource _manualCancellation = new CancellationTokenSource(); - private readonly CancellationTokenSource? _timeoutCancellation; - private readonly CancellationTokenSource _lifetimeCancellation; - private readonly CancellationTokenSource _forcefulCancellation; - private int _disposed; - - internal CliWrapProcessSession( - ProcessSessionStartRequest request, - Command command, - SessionInputPipe inputPipe, - CancellationToken cancellationToken) - { - _request = request; - _inputPipe = inputPipe; - StandardInput = inputPipe.Writer; - _timeoutCancellation = request.Timeout.HasValue - ? new CancellationTokenSource(request.Timeout.Value) - : null; - _lifetimeCancellation = CancellationTokenSource.CreateLinkedTokenSource( - cancellationToken, - _manualCancellation.Token); - _forcefulCancellation = _timeoutCancellation is null - ? CancellationTokenSource.CreateLinkedTokenSource( - _lifetimeCancellation.Token) - : CancellationTokenSource.CreateLinkedTokenSource( - _lifetimeCancellation.Token, - _timeoutCancellation.Token); - - CommandTask commandTask; - try - { - commandTask = command.ExecuteAsync(_forcefulCancellation.Token); - } - catch (Exception exception) when ( - exception is Win32Exception || - exception is InvalidOperationException) - { - DisposeSources(); - throw new PgExecutableStartException(request.ExecutablePath, exception); - } - - Completion = CompleteAsync(commandTask); - } - - public Stream StandardInput { get; } - - public Task Completion { get; } - - public void CompleteInput() => _inputPipe.Complete(); - - public void Cancel() - { - try - { - _manualCancellation.Cancel(); - } - catch (ObjectDisposedException) - { - } - } - - public void Dispose() - { - if (Interlocked.Exchange(ref _disposed, 1) != 0) - return; - - CompleteInput(); - Cancel(); - } - - private async Task CompleteAsync( - CommandTask commandTask) - { - var stopwatch = Stopwatch.StartNew(); - try - { - CommandResult result = await commandTask.ConfigureAwait(false); - stopwatch.Stop(); - return new ProcessSessionResult(result.ExitCode, stopwatch.Elapsed); - } - catch (OperationCanceledException) when (_forcefulCancellation.IsCancellationRequested) - { - stopwatch.Stop(); - - if (_lifetimeCancellation.IsCancellationRequested) - throw new OperationCanceledException(); - - if (_timeoutCancellation is not null && - _timeoutCancellation.IsCancellationRequested) - { - throw new PgProcessTimeoutException( - _request.ExecutablePath, - _request.Timeout!.Value); - } - - throw; - } - finally - { - DisposeSources(); - } - } - - private void DisposeSources() - { - _inputPipe.Dispose(); - _forcefulCancellation.Dispose(); - _lifetimeCancellation.Dispose(); - _timeoutCancellation?.Dispose(); - _manualCancellation.Dispose(); - } - } - private sealed class SessionInputPipe : IDisposable { private const int SegmentSize = 64 * 1024; @@ -573,11 +453,9 @@ private sealed class SessionInputPipe : IDisposable internal SessionInputPipe() { - Source = PipeSource.Create(PumpAsync); Writer = new SessionInputStream(this); } - internal PipeSource Source { get; } internal Stream Writer { get; } internal void Enqueue(byte[] buffer, int offset, int count) @@ -714,7 +592,7 @@ private void EnqueueAcquiredSegment( _signal.Release(); } - private async Task PumpAsync( + internal async Task PumpAsync( Stream destination, CancellationToken cancellationToken) { diff --git a/src/PgCliSharp/PgCliSharp.csproj b/src/PgCliSharp/PgCliSharp.csproj index 9f21ac6..e9efe22 100644 --- a/src/PgCliSharp/PgCliSharp.csproj +++ b/src/PgCliSharp/PgCliSharp.csproj @@ -37,6 +37,6 @@ - + diff --git a/tests/PgCliSharp.TestProcess/Program.cs b/tests/PgCliSharp.TestProcess/Program.cs index 9bb5335..bbfca4c 100644 --- a/tests/PgCliSharp.TestProcess/Program.cs +++ b/tests/PgCliSharp.TestProcess/Program.cs @@ -8,6 +8,29 @@ void Record(string stage) Console.Error.WriteLine("ready"); Console.Error.Flush(); Record("ready"); +if (args.Length > 0 && args[0] == "arguments") +{ + foreach (string argument in args.Skip(1)) + Console.WriteLine(Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(argument))); + Console.WriteLine("environment:" + Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(Environment.GetEnvironmentVariable("PGCLI_TEST_VALUE") ?? string.Empty))); + return; +} +if (args.Length == 1 && args[0] == "exit") +{ + Console.Error.WriteLine("fixture-exit"); + Environment.ExitCode = 17; + return; +} +if (args.Length == 2 && (args[0] == "gate" || args[0] == "gate-exit")) +{ + // The parent releases this owned fixture only after startup has returned. + while (!File.Exists(args[1])) Thread.Sleep(10); + if (args[0] == "gate-exit") + { + Environment.ExitCode = 17; + return; + } +} if (args.Length == 1 && args[0] == "produce") { using Stream producer = Console.OpenStandardOutput(); @@ -24,6 +47,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"); @@ -33,3 +64,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; +} diff --git a/tests/PgCliSharp.Tests/ProcessCompatibilityTests.cs b/tests/PgCliSharp.Tests/ProcessCompatibilityTests.cs new file mode 100644 index 0000000..791a11d --- /dev/null +++ b/tests/PgCliSharp.Tests/ProcessCompatibilityTests.cs @@ -0,0 +1,112 @@ +using System.Diagnostics; +using System.Text; +using PgCliSharp.Internal.Execution; + +namespace PgCliSharp.Tests; + +public sealed class ProcessCompatibilityTests +{ +#if NET48 + [Fact] + public async Task FiniteWithoutInput_PreservesLegacyEof() + { + (string executable, string[] arguments) = ManagedTestProcess.Command(); + using var output = new MemoryStream(); + Task completion = new ProcessRunner().RunAsync( + new ProcessRunRequest(executable, arguments, output, TimeSpan.FromSeconds(15)), CancellationToken.None); + Assert.Same(completion, await Task.WhenAny(completion, Task.Delay(5000))); + Assert.Equal(0, (await completion).ExitCode); + Assert.Empty(output.ToArray()); + } +#endif + + [Fact] + public async Task NativeArguments_PreserveEmptyQuotesBackslashesUnicodeAndEnvironment() + { + string[] values = { "", "plain", "two words", "tab\there", "\"", "a\\\"b", "folder with spaces\\", "日本語🙂", "';&|$()", "line\nbreak" }; + (string executable, string[] arguments) = ManagedTestProcess.Command(values.Prepend("arguments").ToArray()); + using var output = new MemoryStream(); + var request = new ProcessRunRequest(executable, arguments, output, TimeSpan.FromSeconds(15), + environmentVariables: new Dictionary { ["PGCLI_TEST_VALUE"] = "環境 value" }); + ProcessRunResult result = await new ProcessRunner().RunAsync(request, CancellationToken.None); + string[] actual = Encoding.UTF8.GetString(output.ToArray()).Replace("\r\n", "\n").TrimEnd('\n').Split('\n'); + Assert.Equal(0, result.ExitCode); + Assert.Equal(values.Select(value => Convert.ToBase64String(Encoding.UTF8.GetBytes(value))) + .Concat(new[] { "environment:" + Convert.ToBase64String(Encoding.UTF8.GetBytes("環境 value")) }), actual); + Assert.True(output.CanWrite); + } + + [Fact] + public async Task ExitWait_ObservesAnAlreadyExitedOwnedProcess() + { + (string executable, string[] arguments) = ManagedTestProcess.Command("exit"); + var info = new ProcessStartInfo { FileName = executable, UseShellExecute = false, CreateNoWindow = true }; + ProcessCompatibility.SetArguments(info, arguments); + using var process = new Process { StartInfo = info }; + Assert.True(process.Start()); + try + { + Assert.True(process.WaitForExit(10000)); + Task completion = ProcessCompatibility.WaitForExitAsync(process); + Assert.Same(completion, await Task.WhenAny(completion, Task.Delay(5000))); + await completion; + Assert.Equal(17, process.ExitCode); + } + finally + { + if (!process.HasExited) ProcessCompatibility.TryTerminateProcessTree(process); + } + } + + [Fact] + public async Task EarlySessionExit_DoesNotRequireExplicitInputEof() + { + (string executable, string[] arguments) = ManagedTestProcess.Command("exit"); + using var output = new MemoryStream(); + using var error = new MemoryStream(); + using IProcessSession session = new ProcessSessionRunner().Start( + new ProcessSessionStartRequest(executable, arguments, output, error, TimeSpan.FromSeconds(15)), CancellationToken.None); + Assert.Same(session.Completion, await Task.WhenAny(session.Completion, Task.Delay(5000))); + Assert.Equal(17, (await session.Completion).ExitCode); + Assert.Contains("fixture-exit", Encoding.UTF8.GetString(error.ToArray())); + Assert.True(output.CanWrite); + Assert.True(error.CanWrite); + } + + [Fact] + public async Task FiniteNonzeroExit_MapsStatusAndCapturedStderr() + { + (string executable, string[] arguments) = ManagedTestProcess.Command("exit"); + var runner = new ProcessRunner(); + ProcessRunResult result = await runner.RunAsync(new ProcessRunRequest(executable, arguments, throwOnNonZeroExitCode: false), CancellationToken.None); + Assert.Equal(17, result.ExitCode); + Assert.Contains("fixture-exit", result.StandardError); + PgProcessExecutionException failure = await Assert.ThrowsAsync(() => + runner.RunAsync(new ProcessRunRequest(executable, arguments), CancellationToken.None)); + Assert.Equal(17, failure.ExitCode); + } + + [Fact] + public async Task EarlySessionExit_ReleasesAProducerBlockedOnInput() + { + string gate = Path.Combine(Path.GetTempPath(), "pgcli-exit-gate-" + Guid.NewGuid().ToString("N")); + (string executable, string[] arguments) = ManagedTestProcess.Command("gate-exit", gate); + using IProcessSession session = new ProcessSessionRunner().Start( + new ProcessSessionStartRequest(executable, arguments, Stream.Null, Stream.Null, TimeSpan.FromSeconds(15)), CancellationToken.None); + Task write = session.StandardInput.WriteAsync(new byte[4 * 1024 * 1024], 0, 4 * 1024 * 1024, CancellationToken.None); + try + { + Assert.False(write.IsCompleted); + File.WriteAllText(gate, string.Empty); + Assert.Same(session.Completion, await Task.WhenAny(session.Completion, Task.Delay(5000))); + Assert.Equal(17, (await session.Completion).ExitCode); + Assert.Same(write, await Task.WhenAny(write, Task.Delay(5000))); + await Assert.ThrowsAnyAsync(() => write); + } + finally + { + session.Cancel(); + File.Delete(gate); + } + } +} diff --git a/tests/PgCliSharp.Tests/ProcessRunnerTests.cs b/tests/PgCliSharp.Tests/ProcessRunnerTests.cs index 6ad38d4..c03e5ba 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() { @@ -194,7 +232,6 @@ public async Task RunAsync_BinaryStandardInput_IsForwardedByteForByte() Assert.Equal(expected, output.ToArray()); } -#if NET8_0_OR_GREATER [Fact] public async Task RunAsync_NonCooperativeOutput_DoesNotMakeCancellationUnbounded() { @@ -221,9 +258,7 @@ public async Task RunAsync_NonCooperativeOutput_DoesNotMakeCancellationUnbounded output.Release(); } -#endif -#if NET8_0_OR_GREATER [Fact] public async Task RunAsync_OutputWriteFault_TerminatesProducerAndPropagatesOriginalFailure() { @@ -269,7 +304,6 @@ public async Task RunAsync_InputReadFault_TerminatesConsumerAndPropagatesOrigina await Assert.ThrowsAsync(() => runTask); Assert.False(input.IsDisposed); } -#endif private static async Task WaitForFileAsync( string path, @@ -359,7 +393,6 @@ private static (string Executable, string[] Arguments) GetContinuousOutputComman new[] { "-c", "while :; do printf 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\\n'; done" }); } -#if NET8_0_OR_GREATER private sealed class BlockingWriteStream : Stream { private readonly TaskCompletionSource _writeStarted = @@ -401,6 +434,13 @@ public override void Write(byte[] buffer, int offset, int count) _release.Task.GetAwaiter().GetResult(); } + public override async Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + { + _writeStarted.TrySetResult(true); + await _release.Task.ConfigureAwait(false); + } + +#if NET8_0_OR_GREATER public override async ValueTask WriteAsync( ReadOnlyMemory buffer, CancellationToken cancellationToken = default) @@ -408,6 +448,7 @@ public override async ValueTask WriteAsync( _writeStarted.TrySetResult(true); await _release.Task.ConfigureAwait(false); } +#endif protected override void Dispose(bool disposing) { @@ -428,8 +469,6 @@ public override void SetLength(long value) => throw new NotSupportedException(); } -#endif - private sealed class ThrowingWriteStream : Stream { internal bool IsDisposed { get; private set; } diff --git a/tests/PgCliSharp.Tests/ProcessSessionRunnerTests.cs b/tests/PgCliSharp.Tests/ProcessSessionRunnerTests.cs index 2803157..9d4d84a 100644 --- a/tests/PgCliSharp.Tests/ProcessSessionRunnerTests.cs +++ b/tests/PgCliSharp.Tests/ProcessSessionRunnerTests.cs @@ -99,7 +99,6 @@ await Assert.ThrowsAnyAsync( () => session.Completion); } -#if NET8_0_OR_GREATER [Fact] public async Task Session_NonCooperativeOutput_DoesNotMakeCancellationUnbounded() { @@ -130,9 +129,7 @@ await Assert.ThrowsAnyAsync( output.Release(); } -#endif -#if NET8_0_OR_GREATER [Fact] public async Task Session_OutputWriteFault_TerminatesProducerAndPropagatesOriginalFailure() { @@ -158,7 +155,6 @@ await Assert.ThrowsAsync( () => session.Completion); Assert.False(output.IsDisposed); } -#endif #if NET48 [Fact] @@ -228,6 +224,7 @@ public async Task Session_LegacyInputBuffer_ReleasesBlockedWriterWhenSessionEnds await Assert.ThrowsAnyAsync( () => session.Completion); } + #endif private static (string Executable, string[] Arguments) GetBinaryEchoCommand() @@ -252,7 +249,6 @@ private static (string Executable, string[] Arguments) GetBinaryEchoCommand() private static (string Executable, string[] Arguments) GetContinuousOutputCommand() => ManagedTestProcess.Command("produce"); -#if NET8_0_OR_GREATER private sealed class BlockingWriteStream : Stream { private readonly TaskCompletionSource _writeStarted = @@ -294,6 +290,13 @@ public override void Write(byte[] buffer, int offset, int count) _release.Task.GetAwaiter().GetResult(); } + public override async Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + { + _writeStarted.TrySetResult(true); + await _release.Task.ConfigureAwait(false); + } + +#if NET8_0_OR_GREATER public override async ValueTask WriteAsync( ReadOnlyMemory buffer, CancellationToken cancellationToken = default) @@ -301,6 +304,7 @@ public override async ValueTask WriteAsync( _writeStarted.TrySetResult(true); await _release.Task.ConfigureAwait(false); } +#endif protected override void Dispose(bool disposing) { @@ -321,8 +325,6 @@ public override void SetLength(long value) => throw new NotSupportedException(); } -#endif - private sealed class ThrowingWriteStream : Stream { internal bool IsDisposed { get; private set; } 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); + } +}