From e9308c9168f1b2430b8c5c66bd65402b1c3c935e Mon Sep 17 00:00:00 2001 From: AG <288536783+codegiveness@users.noreply.github.com> Date: Sat, 10 Oct 2026 21:01:29 +0700 Subject: [PATCH 1/2] perf: send transaction setup in the same batch as the first statement Saves one network round trip per tool call. SqlSession no longer exposes its connection; every statement runs through a batch that carries any pending SET TRANSACTION READ ONLY/READ WRITE and timeouts. --- CHANGELOG.md | 1 + README.md | 2 + src/PostgreSqlMcp.Core/DatabaseLockCheck.cs | 6 +- src/PostgreSqlMcp.Core/SqlExecutor.cs | 79 ++++++++++++++++++--- src/PostgreSqlMcp.Tools/PlanTools.cs | 24 +++---- 5 files changed, 85 insertions(+), 27 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1779907..b0d1895 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Unreleased +- Performance: every tool call saves one network round trip. The transaction setup (`SET TRANSACTION READ ONLY`/`READ WRITE`, `standard_conforming_strings`, statement and lock timeouts) now travels in the same batch as the first statement. With 25 ms added to each server response, median `execute_sql` latency fell from 106.8 ms to 80.8 ms. `SqlSession` no longer exposes its connection, so every statement goes through a batch that carries any pending setup. - Show a grouped README badge table covering build/tests, security scanning, supply chain, registries, platforms, stack versions, features and project activity. Feature and practice badges link to the documentation or configuration that supports them; stack versions are read live from `global.json` and `Directory.Packages.props`. - Release npm job: install npm 12.2.0 from its registry tarball after a pinned SHA-512 check matching the published `dist.integrity`, replacing the unpinned `npm install --global` flagged by Scorecard Pinned-Dependencies. - Release npm job: accept npm 12's one-element JSON array from `npm view @ version --json`. Previously an already-published version failed with "requires an element of type 'String'" instead of being reported as not republished. diff --git a/README.md b/README.md index d39e277..d0c1d86 100644 --- a/README.md +++ b/README.md @@ -490,6 +490,8 @@ An extended run added 3,200 successful queries, 50 errors, 50 JSON plans and one For one synthetic 32-row response over 2,000 warmed serialization calls, allocated bytes per response fell from 36,304 to 27,736 (23.6%) after replacing the temporary JSON document/clone round trip with `SerializeToElement`. This is an allocation measurement, not a throughput or model-token claim. +Each tool call sends the transaction setup (`SET TRANSACTION READ ONLY`/`READ WRITE` and timeouts) in the same network batch as its first statement, so a single-statement call costs two round trips (setup + statement, then `ROLLBACK`/`COMMIT`) instead of three. Through a local proxy adding 25 ms to each server response, the median `execute_sql` latency fell from 106.8 ms to 80.8 ms (60 calls each); savings scale with the network round-trip time and are not a throughput claim. + SDK/provider payload logging is disabled even at debug/trace levels; host diagnostics stay on stderr. Query results, metadata and workload text may still contain sensitive data readable by the role. This is not general-purpose data redaction. See [SECURITY.md](SECURITY.md). diff --git a/src/PostgreSqlMcp.Core/DatabaseLockCheck.cs b/src/PostgreSqlMcp.Core/DatabaseLockCheck.cs index 85032f1..1486b35 100644 --- a/src/PostgreSqlMcp.Core/DatabaseLockCheck.cs +++ b/src/PostgreSqlMcp.Core/DatabaseLockCheck.cs @@ -86,10 +86,12 @@ private static Task> ReadTextAsync(SqlExecutor executor, string dat IReadOnlyDictionary? parameters, CancellationToken ct) => executor.WithSessionAsync(database, async (session, token) => { - await using var command = new NpgsqlCommand(sql, session.Connection, session.Transaction); + await using NpgsqlBatch batch = session.CreateBatch(); + var command = new NpgsqlBatchCommand(sql); if (parameters is not null) foreach (var (name, value) in parameters) command.Parameters.AddWithValue(name, value ?? DBNull.Value); - await using var reader = await command.ExecuteReaderAsync(token).ConfigureAwait(false); + batch.BatchCommands.Add(command); + await using var reader = await session.ExecuteReaderAsync(batch, System.Data.CommandBehavior.Default, token).ConfigureAwait(false); var values = new List(); while (await reader.ReadAsync(token).ConfigureAwait(false)) values.Add(reader.GetString(0)); return values; diff --git a/src/PostgreSqlMcp.Core/SqlExecutor.cs b/src/PostgreSqlMcp.Core/SqlExecutor.cs index c9918d5..b598f86 100644 --- a/src/PostgreSqlMcp.Core/SqlExecutor.cs +++ b/src/PostgreSqlMcp.Core/SqlExecutor.cs @@ -29,11 +29,15 @@ public async Task WithSessionAsync(string database, Func WithSessionAsync(string database, Func _calls.Dispose(); } -public sealed class SqlSession(string database, NpgsqlConnection connection, NpgsqlTransaction transaction, - ServerOptions options, bool readOnly) +/// +/// One server-owned transaction. The connection is not exposed: every statement goes through +/// , which prepends the pending transaction setup (READ ONLY/READ WRITE and timeouts) +/// to the first batch, so no caller can run SQL before the transaction mode is set. +/// +public sealed class SqlSession { - public NpgsqlConnection Connection { get; } = connection; - public NpgsqlTransaction Transaction { get; } = transaction; + private readonly string database; + private readonly NpgsqlConnection connection; + private readonly NpgsqlTransaction transaction; + private readonly ServerOptions options; + private readonly bool readOnly; + private string[]? pendingSetup; + + internal SqlSession(string database, NpgsqlConnection connection, NpgsqlTransaction transaction, + ServerOptions options, bool readOnly, string[] setup) + { + this.database = database; + this.connection = connection; + this.transaction = transaction; + this.options = options; + this.readOnly = readOnly; + pendingSetup = setup; + } + + /// Creates an empty batch bound to this session's transaction; run it with . + public NpgsqlBatch CreateBatch(int? timeout = null) => new(connection, transaction) { Timeout = timeout ?? options.QueryTimeout }; + + /// Runs , preceded by the transaction setup if it has not reached the server yet. + public async Task ExecuteReaderAsync(NpgsqlBatch batch, CommandBehavior behavior, CancellationToken ct) + { + // Each batch command is one extended-protocol statement, so the setup is one command per statement. + string[]? setup = pendingSetup; + if (setup is not null) + for (int i = setup.Length - 1; i >= 0; i--) batch.BatchCommands.Insert(0, new NpgsqlBatchCommand(setup[i])); + try + { + NpgsqlDataReader reader = await batch.ExecuteReaderAsync(behavior, ct).ConfigureAwait(false); + pendingSetup = null; + return reader; + } + // The server processed the batch: either the setup ran or the transaction is aborted. Otherwise + // (cancelled before sending, I/O failure) the setup stays pending for any later statement. + catch (PostgresException) when (setup is not null) { pendingSetup = null; throw; } + } + + /// Runs one fixed control statement and discards its result. + public async Task ExecuteAsync(string sql, CancellationToken ct, int? timeout = null) + { + await using NpgsqlBatch batch = CreateBatch(timeout); + batch.BatchCommands.Add(new NpgsqlBatchCommand(sql)); + await using NpgsqlDataReader reader = await ExecuteReaderAsync(batch, CommandBehavior.Default, ct).ConfigureAwait(false); + } + + /// Marks this session's pooled connections for disposal instead of reuse. + public void ClearPool() => NpgsqlConnection.ClearPool(connection); public async Task QueryAsync(string sql, IReadOnlyDictionary? parameters = null, int? limit = null, int offset = 0, CancellationToken ct = default) @@ -71,10 +126,12 @@ public async Task QueryAsync(string sql, IReadOnlyDictionary 128) throw new ToolException("result_too_wide", "Select at most 128 columns."); var columns = new ColumnInfo[reader.FieldCount]; for (int col = 0; col < columns.Length; col++) diff --git a/src/PostgreSqlMcp.Tools/PlanTools.cs b/src/PostgreSqlMcp.Tools/PlanTools.cs index dedd579..ac31f84 100644 --- a/src/PostgreSqlMcp.Tools/PlanTools.cs +++ b/src/PostgreSqlMcp.Tools/PlanTools.cs @@ -59,14 +59,14 @@ public Task ExplainQuery(CancellationToken ct, string database, using JsonDocument baseline = await ReadPlan(session, statement, false, token); var hypotheticalIndexes = new List(); // Candidates are independent; send one batch rather than one round trip per index. - await using var batch = new NpgsqlBatch(session.Connection, session.Transaction) { Timeout = options.QueryTimeout }; + await using var batch = session.CreateBatch(); foreach (string candidate in candidates) { var command = new NpgsqlBatchCommand($"SELECT indexrelid::bigint, indexname FROM {ns}.hypopg_create_index(@candidate)"); command.Parameters.AddWithValue("candidate", candidate); batch.BatchCommands.Add(command); } - await using (var reader = await batch.ExecuteReaderAsync(CommandBehavior.SequentialAccess, token)) + await using (var reader = await session.ExecuteReaderAsync(batch, CommandBehavior.SequentialAccess, token)) for (int candidate = 0; candidate < candidates.Length; candidate++) { int created = 0; @@ -122,7 +122,7 @@ public Task ExplainQuery(CancellationToken ct, string database, catch { // Clearing marks checked-out connectors for disposal on return to the pool. - NpgsqlConnection.ClearPool(session.Connection); + session.ClearPool(); if (!failed) throw new ToolException("hypopg_cleanup_failed", "Hypothetical index cleanup failed. The connection pool was cleared so this session cannot leak candidates to later calls."); } @@ -157,10 +157,12 @@ private string[] ValidateCandidates(string[]? indexes) private async Task ReadPlan(SqlSession session, string sql, bool analyze, CancellationToken ct) { - await using var command = Command(session, analyze + await using NpgsqlBatch batch = session.CreateBatch(); + batch.BatchCommands.Add(new NpgsqlBatchCommand(analyze ? "EXPLAIN (FORMAT JSON, ANALYZE TRUE, BUFFERS TRUE, VERBOSE FALSE) " + sql - : "EXPLAIN (FORMAT JSON, VERBOSE FALSE) " + sql); - await using var reader = await command.ExecuteReaderAsync(CommandBehavior.SequentialAccess | CommandBehavior.SingleRow, ct); + : "EXPLAIN (FORMAT JSON, VERBOSE FALSE) " + sql)); + // EXPLAIN returns one row; SingleRow is not used because the batch may begin with the session's setup commands. + await using var reader = await session.ExecuteReaderAsync(batch, CommandBehavior.SequentialAccess, ct); if (!await reader.ReadAsync(ct) || reader.IsDBNull(0)) throw new ToolException("invalid_plan", "PostgreSQL returned no JSON plan."); using TextReader text = await reader.GetTextReaderAsync(0, ct); @@ -197,14 +199,8 @@ private async Task ReadPlan(SqlSession session, string sql, bool a private ToolException OversizedPlan() => new("plan_too_large", $"Planner JSON exceeds MaxResultBytes ({options.MaxResultBytes}); no plan was silently truncated. Narrow the query or explain a smaller subquery, or ask the operator to increase the byte limit. Summary also requires reading the bounded complete planner JSON."); - private NpgsqlCommand Command(SqlSession session, string sql, int? timeout = null) => - new(sql, session.Connection, session.Transaction) { CommandTimeout = timeout ?? options.QueryTimeout }; - - private async Task ExecuteControl(SqlSession session, string sql, CancellationToken ct, int? timeout = null) - { - await using var command = Command(session, sql, timeout); - await command.ExecuteNonQueryAsync(ct); - } + private static Task ExecuteControl(SqlSession session, string sql, CancellationToken ct, int? timeout = null) => + session.ExecuteAsync(sql, ct, timeout); private static string QuoteIdentifier(string value) => "\"" + value.Replace("\"", "\"\"") + "\""; From 99fbc91f64a2a33e79beb7bcf12ed2f893ae8939 Mon Sep 17 00:00:00 2001 From: AG <288536783+codegiveness@users.noreply.github.com> Date: Sat, 10 Oct 2026 22:51:32 +0700 Subject: [PATCH 2/2] perf: SqlSession owns its batches instead of exposing CreateBatch ExecuteReaderAsync now takes the commands, builds the batch itself, prepends the pending transaction setup and returns a SessionReader that disposes the batch with the reader, so no caller can execute SQL before the mode is set. --- CHANGELOG.md | 2 +- src/PostgreSqlMcp.Core/DatabaseLockCheck.cs | 5 +- src/PostgreSqlMcp.Core/SqlExecutor.cs | 68 +++++++++++++++------ src/PostgreSqlMcp.Tools/PlanTools.cs | 45 +++++++------- 4 files changed, 76 insertions(+), 44 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b0d1895..3210f7f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ ## Unreleased -- Performance: every tool call saves one network round trip. The transaction setup (`SET TRANSACTION READ ONLY`/`READ WRITE`, `standard_conforming_strings`, statement and lock timeouts) now travels in the same batch as the first statement. With 25 ms added to each server response, median `execute_sql` latency fell from 106.8 ms to 80.8 ms. `SqlSession` no longer exposes its connection, so every statement goes through a batch that carries any pending setup. +- Performance: every tool call saves one network round trip. The transaction setup (`SET TRANSACTION READ ONLY`/`READ WRITE`, `standard_conforming_strings`, statement and lock timeouts) now travels in the same batch as the first statement. With 25 ms added to each server response, median `execute_sql` latency fell from 106.8 ms to 80.8 ms. `SqlSession` no longer exposes its connection, transaction or any batch: it builds and owns every batch itself and prepends any pending setup, so no caller can run SQL before the transaction mode is set. - Show a grouped README badge table covering build/tests, security scanning, supply chain, registries, platforms, stack versions, features and project activity. Feature and practice badges link to the documentation or configuration that supports them; stack versions are read live from `global.json` and `Directory.Packages.props`. - Release npm job: install npm 12.2.0 from its registry tarball after a pinned SHA-512 check matching the published `dist.integrity`, replacing the unpinned `npm install --global` flagged by Scorecard Pinned-Dependencies. - Release npm job: accept npm 12's one-element JSON array from `npm view @ version --json`. Previously an already-published version failed with "requires an element of type 'String'" instead of being reported as not republished. diff --git a/src/PostgreSqlMcp.Core/DatabaseLockCheck.cs b/src/PostgreSqlMcp.Core/DatabaseLockCheck.cs index 1486b35..8f8676d 100644 --- a/src/PostgreSqlMcp.Core/DatabaseLockCheck.cs +++ b/src/PostgreSqlMcp.Core/DatabaseLockCheck.cs @@ -86,12 +86,11 @@ private static Task> ReadTextAsync(SqlExecutor executor, string dat IReadOnlyDictionary? parameters, CancellationToken ct) => executor.WithSessionAsync(database, async (session, token) => { - await using NpgsqlBatch batch = session.CreateBatch(); var command = new NpgsqlBatchCommand(sql); if (parameters is not null) foreach (var (name, value) in parameters) command.Parameters.AddWithValue(name, value ?? DBNull.Value); - batch.BatchCommands.Add(command); - await using var reader = await session.ExecuteReaderAsync(batch, System.Data.CommandBehavior.Default, token).ConfigureAwait(false); + await using SessionReader result = await session.ExecuteReaderAsync([command], System.Data.CommandBehavior.Default, token).ConfigureAwait(false); + NpgsqlDataReader reader = result.Reader; var values = new List(); while (await reader.ReadAsync(token).ConfigureAwait(false)) values.Add(reader.GetString(0)); return values; diff --git a/src/PostgreSqlMcp.Core/SqlExecutor.cs b/src/PostgreSqlMcp.Core/SqlExecutor.cs index b598f86..bf92baa 100644 --- a/src/PostgreSqlMcp.Core/SqlExecutor.cs +++ b/src/PostgreSqlMcp.Core/SqlExecutor.cs @@ -59,9 +59,9 @@ public async Task WithSessionAsync(string database, Func -/// One server-owned transaction. The connection is not exposed: every statement goes through -/// , which prepends the pending transaction setup (READ ONLY/READ WRITE and timeouts) -/// to the first batch, so no caller can run SQL before the transaction mode is set. +/// One server-owned transaction. The connection, transaction and batches are not exposed: every statement goes through +/// , which builds the batch itself and prepends the pending transaction setup +/// (READ ONLY/READ WRITE and timeouts) to the first one, so no caller can run SQL before the transaction mode is set. /// public sealed class SqlSession { @@ -83,33 +83,44 @@ internal SqlSession(string database, NpgsqlConnection connection, NpgsqlTransact pendingSetup = setup; } - /// Creates an empty batch bound to this session's transaction; run it with . - public NpgsqlBatch CreateBatch(int? timeout = null) => new(connection, transaction) { Timeout = timeout ?? options.QueryTimeout }; - - /// Runs , preceded by the transaction setup if it has not reached the server yet. - public async Task ExecuteReaderAsync(NpgsqlBatch batch, CommandBehavior behavior, CancellationToken ct) + /// + /// Runs as one batch, preceded by the transaction setup if it has not reached the server yet. + /// Dispose the result to release the reader and the batch. + /// + public async Task ExecuteReaderAsync(IReadOnlyList commands, CommandBehavior behavior, + CancellationToken ct, int? timeout = null) { + var batch = new NpgsqlBatch(connection, transaction) { Timeout = timeout ?? options.QueryTimeout }; // Each batch command is one extended-protocol statement, so the setup is one command per statement. string[]? setup = pendingSetup; if (setup is not null) - for (int i = setup.Length - 1; i >= 0; i--) batch.BatchCommands.Insert(0, new NpgsqlBatchCommand(setup[i])); + foreach (string statement in setup) batch.BatchCommands.Add(new NpgsqlBatchCommand(statement)); + foreach (NpgsqlBatchCommand command in commands) batch.BatchCommands.Add(command); try { NpgsqlDataReader reader = await batch.ExecuteReaderAsync(behavior, ct).ConfigureAwait(false); pendingSetup = null; - return reader; + return new SessionReader(batch, reader); + } + catch (PostgresException) when (setup is not null) + { + // The server processed the batch: either the setup ran or the transaction is aborted. Otherwise + // (cancelled before sending, I/O failure) the setup stays pending for any later statement. + pendingSetup = null; + await batch.DisposeAsync().ConfigureAwait(false); + throw; + } + catch + { + await batch.DisposeAsync().ConfigureAwait(false); + throw; } - // The server processed the batch: either the setup ran or the transaction is aborted. Otherwise - // (cancelled before sending, I/O failure) the setup stays pending for any later statement. - catch (PostgresException) when (setup is not null) { pendingSetup = null; throw; } } /// Runs one fixed control statement and discards its result. public async Task ExecuteAsync(string sql, CancellationToken ct, int? timeout = null) { - await using NpgsqlBatch batch = CreateBatch(timeout); - batch.BatchCommands.Add(new NpgsqlBatchCommand(sql)); - await using NpgsqlDataReader reader = await ExecuteReaderAsync(batch, CommandBehavior.Default, ct).ConfigureAwait(false); + await using SessionReader result = await ExecuteReaderAsync([new NpgsqlBatchCommand(sql)], CommandBehavior.Default, ct, timeout).ConfigureAwait(false); } /// Marks this session's pooled connections for disposal instead of reuse. @@ -126,12 +137,11 @@ public async Task QueryAsync(string sql, IReadOnlyDictionary 128) throw new ToolException("result_too_wide", "Select at most 128 columns."); var columns = new ColumnInfo[reader.FieldCount]; for (int col = 0; col < columns.Length; col++) @@ -225,3 +235,23 @@ public async Task QueryAsync(string sql, IReadOnlyDictionaryA result reader that owns the batch it reads from; disposing it releases both. +public sealed class SessionReader : IAsyncDisposable +{ + private readonly NpgsqlBatch batch; + + internal SessionReader(NpgsqlBatch batch, NpgsqlDataReader reader) + { + this.batch = batch; + Reader = reader; + } + + public NpgsqlDataReader Reader { get; } + + public async ValueTask DisposeAsync() + { + try { await Reader.DisposeAsync().ConfigureAwait(false); } + finally { await batch.DisposeAsync().ConfigureAwait(false); } + } +} diff --git a/src/PostgreSqlMcp.Tools/PlanTools.cs b/src/PostgreSqlMcp.Tools/PlanTools.cs index ac31f84..302ee61 100644 --- a/src/PostgreSqlMcp.Tools/PlanTools.cs +++ b/src/PostgreSqlMcp.Tools/PlanTools.cs @@ -59,31 +59,34 @@ public Task ExplainQuery(CancellationToken ct, string database, using JsonDocument baseline = await ReadPlan(session, statement, false, token); var hypotheticalIndexes = new List(); // Candidates are independent; send one batch rather than one round trip per index. - await using var batch = session.CreateBatch(); + var commands = new List(candidates.Length); foreach (string candidate in candidates) { var command = new NpgsqlBatchCommand($"SELECT indexrelid::bigint, indexname FROM {ns}.hypopg_create_index(@candidate)"); command.Parameters.AddWithValue("candidate", candidate); - batch.BatchCommands.Add(command); + commands.Add(command); } - await using (var reader = await session.ExecuteReaderAsync(batch, CommandBehavior.SequentialAccess, token)) - for (int candidate = 0; candidate < candidates.Length; candidate++) + await using (SessionReader result = await session.ExecuteReaderAsync(commands, CommandBehavior.SequentialAccess, token)) { - int created = 0; - while (await reader.ReadAsync(token)) + NpgsqlDataReader reader = result.Reader; + for (int candidate = 0; candidate < candidates.Length; candidate++) { - if (++created > 1) - throw new ToolException("invalid_index", "Each candidate must describe exactly one CREATE INDEX statement."); - long oid = reader.GetInt64(0); - string name = reader.GetString(1); - if (name.Length > options.MaxCellChars) - throw new ToolException("result_too_large", "Hypothetical index name exceeds the result cell limit; simplify the candidate."); - hypotheticalIndexes.Add(new(candidate, oid, name)); + int created = 0; + while (await reader.ReadAsync(token)) + { + if (++created > 1) + throw new ToolException("invalid_index", "Each candidate must describe exactly one CREATE INDEX statement."); + long oid = reader.GetInt64(0); + string name = reader.GetString(1); + if (name.Length > options.MaxCellChars) + throw new ToolException("result_too_large", "Hypothetical index name exceeds the result cell limit; simplify the candidate."); + hypotheticalIndexes.Add(new(candidate, oid, name)); + } + if (created != 1) + throw new ToolException("invalid_index", $"Candidate {candidate} did not create a hypothetical index. Supply a supported CREATE INDEX definition."); + if (candidate + 1 < candidates.Length && !await reader.NextResultAsync(token)) + throw new ToolException("invalid_index", "HypoPG did not return a result for each candidate."); } - if (created != 1) - throw new ToolException("invalid_index", $"Candidate {candidate} did not create a hypothetical index. Supply a supported CREATE INDEX definition."); - if (candidate + 1 < candidates.Length && !await reader.NextResultAsync(token)) - throw new ToolException("invalid_index", "HypoPG did not return a result for each candidate."); } using JsonDocument evaluated = await ReadPlan(session, statement, false, token); PlanSummary before = Summarize(baseline.RootElement); @@ -157,12 +160,12 @@ private string[] ValidateCandidates(string[]? indexes) private async Task ReadPlan(SqlSession session, string sql, bool analyze, CancellationToken ct) { - await using NpgsqlBatch batch = session.CreateBatch(); - batch.BatchCommands.Add(new NpgsqlBatchCommand(analyze + var command = new NpgsqlBatchCommand(analyze ? "EXPLAIN (FORMAT JSON, ANALYZE TRUE, BUFFERS TRUE, VERBOSE FALSE) " + sql - : "EXPLAIN (FORMAT JSON, VERBOSE FALSE) " + sql)); + : "EXPLAIN (FORMAT JSON, VERBOSE FALSE) " + sql); // EXPLAIN returns one row; SingleRow is not used because the batch may begin with the session's setup commands. - await using var reader = await session.ExecuteReaderAsync(batch, CommandBehavior.SequentialAccess, ct); + await using SessionReader result = await session.ExecuteReaderAsync([command], CommandBehavior.SequentialAccess, ct); + NpgsqlDataReader reader = result.Reader; if (!await reader.ReadAsync(ct) || reader.IsDBNull(0)) throw new ToolException("invalid_plan", "PostgreSQL returned no JSON plan."); using TextReader text = await reader.GetTextReaderAsync(0, ct);