Skip to content

Part of #2874 - #2931

Merged
erikdarlingdata merged 7 commits into
devfrom
fix/2874-mcp-web-read-timeouts
Sep 4, 2026
Merged

erikdarlingdata merged 7 commits into
devfrom
fix/2874-mcp-web-read-timeouts

Conversation

@erikdarlingdata

@erikdarlingdata erikdarlingdata commented Sep 4, 2026 •

Copy link
Copy Markdown
Owner

Part of #2874 — group A of five, the MCP and web read surface. This does not close the issue; four groups remain.

What was wrong

All 125 command sites on this surface set no CommandTimeout and inherited Npgsql's undocumented 30 s default: 118 under Mcp/ (114 of them the .CreateCommand( shape #2874's census could not see, and Mcp/DarlingDataReader.cs is 26 on its own), the 6 CustomViewStore statements both surfaces persist through, and the 1 composed-query runner in DarlingWebEndpoints.

Census, re-derived — with positive controls first

Not taken from the issue. The landed pins' machinery lifted verbatim — s_commandCtor, s_setsTimeout, the method-group regex, and CSharpSourceWalker.StatementSpanFrom(…, statements: 2) — into a net10.0 harness. The controls are what establish it counts the way the landed pins count:

project pin scope sites by shape untimed
.Storage TopDirectoryOnly 124 74 / 50 0
.Viewer AllDirectories 193 2 / 191 0
.Analysis TopDirectoryOnly 73 71 / 2 0

All three reproduce exactly. .Service then comes out at 261 sites / 201 untimed (130 / 131 by shape) — which agrees with the census comment on the issue to the digit — of which these 125 are group A. Mcp/ alone: 118 sites, all 118 untimed, 4 / 114 by shape. After this change Mcp/ is 118 / 0 and the project is 201 → 76 untimed, the remainder belonging to groups B–E.

The derivation

The enclosing constraint is a kind this sweep had not met. Not a clock (#2871's CancelAfter) and not a connection permit (the viewer group's MaxPoolSize = 10), but a server-side statement_timeout on the login role — ALTER ROLE mcp/viewer SET statement_timeout, written by startup provisioning from config_service.compose_statement_timeout_seconds, shipped default 15 s, clamped 5–600. It is a role setting, so it bounds every statement those identities run and not merely the composed queries it is named for; DarlingTrendReader and QueryStoreTrendRouting both already reason about it by name.

Why a client-side deadline does not simply defer to the GUC

Three reasons, and they land differently per deployment shape rather than being one argument restated.

  1. Bring-your-own PostgreSQL has no such GUC at all. Darling/tools/provision-roles.sql creates admin and viewer and deliberately no mcp role, and sets statement_timeout on viewer only — hardcoded at 15 s, not read from the store, so a BYO operator's change to the column never reaches a role until they re-run the script by hand. The MCP host in that mode connects with the operator's own postgres.connectionString, typically the owner or admin identity, which carries no statement_timeout. So on a BYO store this surface has no server-side bound, and Npgsql's undocumented default is currently the only thing bounding it. A BYO operator also has no knob here at all, which is why these values are deliberately not aggressive.
  2. On a managed store the GUC is operator-tunable to 600 s, and it is per-role. Raising compose_statement_timeout_seconds so one heavy custom view can finish raises it for all 124 shipped reads. The client-side deadline is what keeps the ordinary read bounded while the backstop is deliberately loosened for the extraordinary one.
  3. A role SET takes effect on that role's next session. DarlingConfig says so outright — "this is not a kill switch" — and DarlingManagedRoles logs that an already-connected client keeps the old ceiling until it reconnects. The MCP host builds its pool once at host start. A CommandTimeout is set per command at construction, so it binds the very next command on an already-open pooled connection, which is exactly what the GUC cannot do.

The asymmetry that makes it necessary rather than tidy

None of the 136 [McpServerTool] methods takes a CancellationToken. Every reader declares CancellationToken cancellationToken = default; every tool calls it without one; the single tools file that names a token passes CancellationToken.None explicitly (DarlingMcpCustomViewTools into DarlingWebEndpoints.RunComposedPanelAsync). So an agent that abandons a call leaves the query running and the pooled backend held — nothing on this path propagates abandonment. The web half is the mirror image and threads HttpContext.RequestAborted throughout, so a disconnecting browser does cancel; the deadline is a backstop there and the only bound on the MCP side.

The permit, at its real strength rather than borrowed

The MCP host builds its own data source, separate from the web host's and from the worker's collection pool — three independent NpgsqlDataSource.Create calls on connection strings that differ by Username (mcp, viewer, and the bootstrap owner). Verified in source, not assumed, and it cuts the argument down rather than up: a stalled MCP read cannot starve fleet-wide collection through the pool. The widest fan-out here is a Task.WhenAll over two reads against MaxPoolSize = 24 — nothing like the viewer's ten-against-ten, and there is no CancelAfter, SemaphoreSlim, WaitAsync or CreateLinkedTokenSource anywhere on the path. What the bound still buys is the reason it exists at all: every pooled connection is a live postgres.exe process, each spawn re-reserves the shared memory region, and this store logged 8 could not reserve shared memory region retries on 2026-09-03 — so a longer hold means more spawns, not just a busier pool.

Two regimes, not one

McpCommandDeadlines.ReadSeconds = 20 for the 124 shipped-shape reads.

Floor — the managed store's own ceiling, 15 s, and it fires on this surface in production today. On 2026-09-04 get_query_store_duration_trend over a 7-day window returned 57014: canceling statement due to statement timeout against the live 42-server store, with #2736's corrected-rollup routing already engaged, while the same read at its shipped 24 h default returned its full series. Sitting above 15 s is what keeps the server-side timeout the one that fires on a managed store, because 57014 names its cause where Npgsql's own deadline renders as Exception while reading from stream and gets read as a network fault — the misdiagnosis #2826 exists to prevent. That floor dominates the measured one by more than an order of magnitude; this family's verified reads are sub-second.

Ceiling — the 30 s it replaces, which the viewer group's standard requires it to sit meaningfully under rather than at, plus a relational bound against StorageCommandDeadlines.McpReadSeconds: the DarlingPg*Reader family serves these same 136 tools from .Storage on the same pool, so one tool call must never get a looser ceiling depending on which project happens to hold the reader.

The composed-query runner could not take that constant, and that is the split. It is the one site running operator-authored SQL, and compose_statement_timeout_seconds is the operator's declaration of how long that class may run — a fixed 20 s would cut off the panel the knob was raised to 120 s for, a regression dressed as a fix.

So it takes no constant at all. McpCommandDeadlines.ResolveComposedQuerySecondsAsync reads config.config_service.compose_statement_timeout_seconds and clamps it through the store's own ClampComposeStatementTimeoutSeconds, so the client-side deadline is the operator's actual setting rather than the ceiling they could theoretically have chosen.

Read rather than plumbed, and that is the load-bearing choice. The store is authoritative for this knob and #2918 made it hot-swappable — a control-plane reload re-asserts it onto the roles with no restart. So a value captured at host start is simply wrong after the first change, and the value a host could plumb is the file-loaded DarlingConfig.ComposeStatementTimeoutSeconds, which is only the seed: the store wins afterwards. Reading per panel run has no cache to go stale, and it is also what keeps the two callers identical — MapAll has the host's config in scope while Mcp/DarlingMcpCustomViewTools.cs:252 has only the data source, so plumbing would have handed one surface the file value and the other a store read, which is exactly the divergence the single shared runner exists to prevent.

The near miss is three lines away in the same method. ComposeStoreAvailability.GetRollupsAsync is deliberately cached per data source, because which rollups a store has changes only when someone migrates it. Caching the deadline the same way compiles, passes every band assertion, and pins it to whatever the knob said the first time a panel ran. Resolved once per run — so a panel with annotation overlays pays one read rather than one per source, and every query in one run shares one ceiling, matching the role statement_timeout they all run under.

ComposedQueryFallbackSeconds is 15, the same shipped default DarlingConfig, ComposeSpec and the clamp's own non-positive arm all use, so a store that cannot answer behaves like a store nobody has tuned. Failing open to something larger would grant more time precisely when the store is least able to answer for itself — the wrong direction for the only bound a BYO store gets.

Store vs monitored target — the claim no landed pin needed

.Storage and .Viewer are Npgsql-store-only projects, so "every command sets a deadline" was the whole invariant there. .Service also builds commands against monitored targets, where a store-derived constant would be wrong in a way nothing else would catch: the value would look deliberate while bounding the wrong hop.

Every one of the 125 sites in this scope was classified, and all 125 are store commands — 115 postgres.CreateCommand(, 6 _dataSource.CreateCommand(, and 4 new NpgsqlCommand(sql, connection) whose connection comes from postgres.OpenConnectionAsync. There are no monitored-target commands here: Mcp/ contains no SqlConnection, no SqlCommand, no TargetProviders.For and no new NpgsqlConnection(. A receiver allowlist writes that classification down, with the two real target shapes pinned as negatives — ITargetProvider.CreateCommand(plan, connection, commandTimeoutSeconds), where the deadline is threaded and is stronger than a constant, and the transaction-scoped HypoPG form — so a future target command here fails asking for a decision instead of inheriting a store bound.

The pin

McpReadCommandTimeoutTests, scoped to Mcp/ recursively plus CustomViewStore.cs and DarlingWebEndpoints.cs by name — deliberately not the project. .Service carries roughly fifteen budget regimes over ~200 untimed sites across five groups; a project-wide glob would claim regimes this pin has not derived and would collide with the sibling groups' pins. Mcp/ is globbed because the whole directory is this surface; the two top-level files are named because the project root is shared.

It covers all four known shapes plus one this sweep had not recorded, and asserts zero for the two that should be absent — the bare method group, and BeginBinaryImportAsync, whose NpgsqlBinaryImporter.Timeout is initialised from the connection's CommandTimeout and which no landed pin's regex can see. The project's four live importers are the collection sweep's; asserting their absence here makes "this scope has none" a checked fact rather than an assumption. Both negatives carry positive controls through the identical form, so neither can pass by matching nothing.

Every scan reads the stripped text, including the construction scan — which the landed pins run over raw source, where a prose mention of .CreateCommand( becomes a phantom untimed site at a line number no edit could fix. Not hypothetical in kind: Mcp/DarlingMcpStoreMetricsTools.cs contains the sentence "the two readers above go through postgres.CreateCommand and leave nothing open", and it misses both regexes only because what follows the name is a space and a word rather than a delimiter. The divergence changes no count (125 either way) and closes a false-positive direction.

A fifth construction shape, found because a red-first variant came back green

The planted monitored-target offender used new Npgsql.NpgsqlCommand( and the pin did not see it: no regex in this sweep matches a qualified type name. The form occurs exactly once in the repo — DarlingWorker.cs's pg_stat_statements text fetch — and that site is a monitored-target command already carrying its own 60 s, so the blind spot has no defect behind it and the untimed census is unchanged. The site census for .Service is 262, not 261. Both the construction scan and the receiver allowlist now accept the qualified spelling, with the qualified store form pinned as accepted so the widening cannot create a false positive.

Proven red fifteen ways, one mutation at a time

Each verified applied by reading the file back rather than by a diffstat, and each rebuilt — a mutation that does not compile proves nothing — and each restored from an in-memory byte copy rather than git checkout.

# mutation assertion that failed
1 remove one deadline in the 26-site reader …SetsAnExplicitDeadline — 1 MCP/web read command(s) inherit Npgsql's 30s default…: DarlingDataReader.cs:225
2 remove the deadline inside a block-form using body …SetsAnExplicitDeadline
3 remove the CustomViewStore deadline …SetsAnExplicitDeadline
4 untimed command planted in an Mcp/ subdirectory …SetsAnExplicitDeadline (recursion control)
5 timed monitored-target command planted in Mcp/, unqualified …IsBuiltAgainstTheStore_NotAMonitoredTarget — 1 command(s) … have an unrecognised receiver…: DarlingTargetProbeReader.cs:7
5b the same, fully qualified …IsBuiltAgainstTheStore_NotAMonitoredTarget
6 bare .CreateCommand method-group hand-off planted NoCommandInScope_IsCreatedByABareMethodGroupHandoff
7 untimed BeginBinaryImportAsync planted NoCommandInScope_UsesAnUntimedBinaryCopyImporter
8 ReadSeconds → 15 band: "at or under the mcp role's 15s default statement_timeout, so on a managed store it would fire FIRST and replace a 57014 that names its cause…"
9 ReadSeconds → 5 band: "at or under the 5s floor an operator can clamp the role's own statement_timeout to…"
10 ReadSeconds → 30 band: "not meaningfully under the inherited Npgsql default it replaces…"
11 StorageCommandDeadlines.McpReadSeconds → 15 the relational ceiling, isolated from the numeric one — the two coincide at 30 today, so only this mutation can exercise it
12 ComposedQuerySeconds decoupled from the clamp maximum TheComposedQueryDeadline_TracksTheOperatorsDeclaredMaximum — that constant and that fact are both gone as of the follow-on below; the mutation is retired and its successors are S3–S6
13 receiver allowlist narrowed so a real store shape is rejected TheReceiverAllowlist_… (positive control on the allowlist itself)
15 the receiver allowlist's word-boundary lookbehind removed TheReceiverAllowlist_… on notpostgres.CreateCommand( — without the boundary, targetPostgres.CreateCommand( matches postgres\.CreateCommand as a substring, a false accept in the one guard meant to stop a target command taking a store deadline
14 move CustomViewStore.cs the named-file existence check — a count-based control cannot see a relocation, every occurrence count staying 1 → 1

The follow-on: eight more, and two of them are why the live test exists

# mutation assertion that failed
S1 staleness — memoise the resolved value in a static TheResolver_ReadsTheConfiguredValue_AndSeesItChange
S2 staleness — memoise per data source, the GetRollupsAsync shape same
S3 drop the clamp and trust the column TheResolver_ClampsAnOutOfRangeRow — Expected: 600 / Actual: 6000
S4 fallback 15 → 600 (fail open) TheComposedQueryFallback_IsAValueAnOperatorCouldHaveConfigured
S5 fallback 15 → 0 (CommandTimeout = 0 means no limit) same — Assert.InRange() … Range: (5 - 600) / Actual: 0
S6 revert the runner to a compile-time constant TheComposedQueryRunner_ResolvesItsDeadlinePerRun
S7 strip the deadline from the resolver's own read EveryMcpAndWebReadCommand_SetsAnExplicitDeadline — McpCommandDeadlines.cs:186
S8 move the resolve out of the shared runner into the per-query method TheComposedQueryRunner_ResolvesItsDeadlinePerRun — a MOVE, so ResolveComposedQuerySecondsAsync(postgres stays at 1 → 1 and only the structural ordering control can see it

S1 and S2 first came back GREEN, and that was the mutation's fault rather than the pin's. The planted cache was read but never populated, so both compiled and changed no behaviour. A mutation that compiles but does not move the behaviour proves exactly as little as one that does not compile — "assert the mutation applied" has to mean the behaviour moved, not that the bytes did. Once the cache was actually written, both go red on the change-seeing assertion.

TheResolver_FallsBackOnAMigratedButUnseededStore exists because running the live test found it: MigrateAsync creates config_service, but the row comes from StoreConfigProvider.SeedIfEmptyAsync, which is a separate act. So "brand-new store, first panel run" reads no rows at all, and the resolver has to land on its fallback rather than hand a zero to CommandTimeout. There is a companion for a store with no config_service table at all (a pre-V78 store), which raises 42P01.

The live class mints its own scratch database (ScratchPostgres, with the #1776 own-store marker) rather than joining [Collection("live-postgres")]: every test in it mutates the knob and one asserts the behaviour of an unseeded store, so it cannot share the collection's long-lived database with sixty other classes.

The three landed #2874 pins still pass and still assert what they claim. StorageCommandTimeoutTests (6), AnalysisPassCommandTimeoutTests (13) and AlertPassCommandTimeoutTests (5) all execute green against this tree, scopes unchanged — none of them globs .Service, and the alert-pass pin names six top-level files, none of which this change touches. The viewer pin cannot be compiled on macOS (its project is net10.0-windows WPF), but its scope directory is untouched by this diff and the identical scan reproduces .Viewer at 193 / 0, which is what it asserts.

What this does NOT do

  • No user-reported failure is closed by it. The 7-day Query Store window measured above is bounded by the role, not by this change, and stays bounded by it.
  • No timing was taken with a client-side deadline in place, and the production compose_statement_timeout_seconds was not read back — the 15 s used throughout is the shipped default and the clamp, verified in source rather than on the box. The production evidence is the 57014 above plus successful live executions of get_fleet_overview (the 8-way DarlingFleetReader fan-out over 42 servers), the server-registry read, and the 24 h trend.
  • A local PostgreSQL 17 / TimescaleDB 2.29.2 store was stood up for the follow-on, built by the product's own PgMigrations.MigrateAsync. It was used to prove the resolver's behaviour — that it sees a changed value, clamps an out-of-range row, and falls back on both a migrated-but-unseeded store and a store with no config_service table — not to measure any read's duration. It holds no collected data, so nothing here is a performance measurement.
  • Darling.Tests is net10.0-windows and cannot run on macOS. The 68 tests reported here ran in a throwaway net10.0 xunit host compiling the real source files by absolute path with AssemblyName=Darling.Tests — 68/68 green with DARLING_TEST_PG pointed at that store, 61/68 with 7 skipped without it. CI is the arbiter. Expected delta: +47 — McpReadCommandTimeoutTests 40 plus McpComposedQueryDeadlineLivePostgresTests 7, counted per class rather than derived.
  • The host has to be staged inside the repo tree to exercise LivePostgresCollectionHygieneTests, which resolves its scan directory from AppContext.BaseDirectory: a host built in a scratch directory fails that rule for an unrelated reason and tells you nothing about the class under test. That rule reads only the 25 lines above a class declaration when looking for [Collection("live-postgres")] or an #1776 own-store exemption, so the marker sits immediately above the declaration rather than above the doc comment — the placement the three existing own-store classes all use, and the marker says so.
  • No schema change, no migration rung, no version bump.

All 125 command sites under Mcp/, in CustomViewStore and in the composed-query
runner set no CommandTimeout and inherited Npgsql's undocumented 30 s default.

Two regimes. The 124 shipped-shape reads take McpCommandDeadlines.ReadSeconds
(20), floored above the mcp role's 15 s statement_timeout so the server-side
timeout stays the one that fires on a managed store -- 57014 names its cause
where Npgsql's deadline renders as a stream fault -- and capped below the 30 s
it replaces, relationally against StorageCommandDeadlines.McpReadSeconds so the
two halves of one tool call cannot disagree. The composed-query runner takes
ComposedQuerySeconds, bound by reference to the operator's own clamped maximum,
because compose_statement_timeout_seconds is the declared bound for that query
class and a fixed constant under it would override a deliberate setting.

The pin is scoped to Mcp/ recursively plus the two files by name, and carries a
receiver allowlist: .Service builds commands against monitored targets as well
as the store, so a store-derived deadline landing on a target command has to
fail rather than look deliberate.
A red-first variant planted a monitored-target command in Mcp/ and came back
GREEN. The reason is a shape no regex in this sweep matches: a QUALIFIED type
name, `new Npgsql.NpgsqlCommand(`. It occurs once in the repo, in DarlingWorker's
pg_stat_statements text fetch, which is a monitored-target command that already
carries its own 60 s -- so the blind spot has no defect behind it and the untimed
census does not move, but the site census for .Service is 262 rather than 261.

Both the construction scan and the receiver allowlist now accept the qualified
spelling, and the qualified STORE form is pinned as accepted so the widening
cannot turn a legitimate construction into a false positive.
@claude

claude Bot commented Sep 4, 2026

Copy link
Copy Markdown

Review summary

Reviewed this against CONTRIBUTING.md's C# conventions and the Lite/Darling parity requirement. This PR is purely mechanical — it adds command.CommandTimeout = McpCommandDeadlines.ReadSeconds (or .ComposedQuerySeconds at the one composed-query site) after each NpgsqlCommand/CreateCommand construction under Mcp/, CustomViewStore.cs, and DarlingWebEndpoints.cs. I independently re-derived the census rather than trusting the PR body's own numbers:

  • grep-counted 118 command constructions (new NpgsqlCommand(/new Npgsql.NpgsqlCommand(/.CreateCommand() under Mcp/, and 118 CommandTimeout = McpCommandDeadlines... assignments in the same tree — 1:1 coverage, no misses, no duplicates.
  • CustomViewStore.cs (6) + DarlingWebEndpoints.cs (1) account for the remaining 7, totaling 125 as claimed.
  • Every symbol the new code and doc comments reference resolves: StoreConfigProvider.MaxComposeStatementTimeoutSeconds (600), MinComposeStatementTimeoutSeconds (5), ClampComposeStatementTimeoutSeconds, StorageCommandDeadlines.McpReadSeconds (30), and DarlingConfig.ComposeStatementTimeoutSeconds (default 15) all exist with the values the derivation assumes, so ReadSeconds = 20 really does sit strictly between the 15s role floor and the 30s ceiling it's pinned against.
  • CSharpSourceWalker.StripCommentsAndStrings/StatementSpanFrom (from the just-merged Fixes #2913 #2925 shared-walker PR) are used with the correct signatures.

Lite/Darling parity: no drift. This is an Npgsql/CommandTimeout-specific fix; Lite's MCP surface reads from DuckDB, and DuckDBCommand doesn't have (or reliably honor) a CommandTimeout to inherit — the PR body itself notes this, and there's nothing analogous to add on the Lite side.

Correctness/security/perf: no issues found. Every touched command is a store-bound (not monitored-target) command, so a store-derived constant is appropriate everywhere it's applied; no SQL text changes, no injection surface change. The 20s value only tightens an inherited undocumented 30s Npgsql default, and the composed-query path still defers to the operator's own clamp-derived maximum (600s) rather than a hardcoded value that could cut off a deliberately-widened panel.

One very minor nit, not blocking: in CustomViewStore.cs, the CreateAsync/UpdateAsync/DeleteAsync writes (lines 144, 179, 211) reuse McpCommandDeadlines.ReadSeconds even though they're INSERT/UPDATE/DELETE, not reads. Functionally fine (20s is plenty for a single-row JSONB upsert), just a naming mismatch given the constant's doc comment describes it as covering "124 shipped-shape reads."

… clamp ceiling

The composed-query runner took McpCommandDeadlines.ComposedQuerySeconds, bound to
StoreConfigProvider.MaxComposeStatementTimeoutSeconds (600) -- the operator's
clamped MAXIMUM rather than the value they configured. That never overrode a
deliberate setting on a managed store, where the role GUC fires first, but on a
bring-your-own store there is no role GUC, so 600 s was the real bound.

ResolveComposedQuerySecondsAsync now reads config.config_service's
compose_statement_timeout_seconds and clamps it through the store's own clamp.
READ rather than plumbed, because the store is authoritative and #2918 made the
knob hot-swappable: a value captured at host start is wrong after the first
change, and the value a host could plumb is the file-loaded seed, not the live
one. Reading per run has no cache to go stale and keeps the two callers
identical -- MapAll has the host config in scope, the MCP tool has only the data
source, so plumbing would have given the two surfaces different answers. The
near miss is three lines away: ComposeStoreAvailability.GetRollupsAsync is
deliberately cached per data source, and caching this the same way compiles and
passes every band assertion.

Resolved once per RUN, so a panel with annotation overlays pays one read rather
than one per source, and every query in a run shares one ceiling. Fallback is 15,
the shipped default in three other places; failing open to something larger would
grant more time exactly when the store is least able to answer for itself.

The old fact asserted equality with the clamp maximum and would now state the
opposite of what it checks, so it is replaced: a fallback band, a structural
per-run guard, and a live-Postgres class that CHANGES the column and requires the
next resolve to see it -- the only thing that can tell a per-run read from a
cached one.
@erikdarlingdata

Copy link
Copy Markdown
Owner Author

Thanks — the independent census reproduction is the part I most wanted checked, and the 1:1 coverage count matches mine.

On the naming nit: acted, and the count was low. There are six write sites among the 124, not three — the three you found in CustomViewStore (CreateAsync/UpdateAsync/DeleteAsync) plus three in Mcp/ that a CustomViewStore-scoped look would not see:

  • Mcp/DarlingMcpAlertTools.cs:367 — UPDATE config_alert_settings (the alert-tuning tool)
  • Mcp/DarlingMcpServerAdminTools.cs:219 — DELETE FROM config_monitored_servers (remove_server)
  • Mcp/DarlingMcpServerAdminTools.cs:570 — INSERT into the same table (add_servers)

So 119 reads + 6 writes = 124.

Kept as one constant, and documented rather than renamed, for two reasons. They share the regime exactly — the same least-privilege pool, the same absent CancellationToken, the same role statement_timeout, no enclosing budget — and this sweep groups by regime rather than by verb; and every one is a single-row primary-key statement on a config table, so it is strictly cheaper than the reads the bound was floored on. Renaming would also break the relational pin against StorageCommandDeadlines.McpReadSeconds, whose name it deliberately follows (as does the viewer's InteractiveReadSeconds, which covers that surface's alert-dismiss and mute writes for the same reason). The doc comment on ReadSeconds now says "read" names the SURFACE, not the verb, and enumerates all six; the CHANGELOG entry says the same.

Two things landed after your pass, both worth a re-read:

  1. A fifth construction shape, found because a red-first variant of the new pin came back GREEN: a qualified type name, new Npgsql.NpgsqlCommand(, which no regex in this sweep matches. It occurs exactly once in the repo — DarlingWorker.cs's pg_stat_statements text fetch — and that site is a monitored-target command already carrying its own 60 s, so the blind spot has no defect behind it and the untimed census does not move; the site census for .Service is 262 rather than 261. Both the construction scan and the receiver allowlist now accept it, with the qualified store form pinned as accepted so the widening cannot create a false positive.

  2. The composed-query deadline no longer defers to the clamp maximum. You noted it "still defers to the operator's own clamp-derived maximum (600s) rather than a hardcoded value" — that was the deliberate compromise, and it is now closed. McpCommandDeadlines.ResolveComposedQuerySecondsAsync reads config.config_service.compose_statement_timeout_seconds per panel run and clamps it, so the deadline is the operator's actual setting. Read rather than plumbed because the knob is hot-swappable (The compose statement_timeout only reaches the live roles on a restart, unlike every other config_service knob #2918) and the value a host could plumb is the file-loaded seed, not the live one — and the near miss is three lines away, where ComposeStoreAvailability.GetRollupsAsync is deliberately cached per data source. A live-Postgres class changes the column and requires the next resolve to see it, which is the only thing that can tell a per-run read from a cached one.

Not merging, and #2874 stays open — groups B–E remain.

The structural guard located RunComposedPanelAsync's declaration by matching the
open paren followed by a newline, with a CRLF arm and an LF fallback. The
declaration wraps its parameter list, so that anchor was line-ending-sensitive in
exactly the way a source-scanning pin must not be -- blobs here are LF and working
copies are CRLF. Anchored on the return type instead, which is on the same line as
the name and carries no newline.
@claude

claude Bot commented Sep 4, 2026

Copy link
Copy Markdown

Reviewed the diff in full (all 34 modified/added .cs files plus the two new test files). Summary:

Correctness — Every CreateCommand/new NpgsqlCommand site touched gets command.CommandTimeout = McpCommandDeadlines.ReadSeconds immediately after construction, including the block-using shapes (DarlingHealthReader, DarlingBlockingReader) where the statement lands inside the body rather than right after the declaration. Cross-checked every hunk by hand against the 125+1 site count claimed in the PR body — didn't find a missed or misplaced site. The DarlingWebEndpoints/CustomViewStore/McpCommandDeadlines changes for the composed-query path are correct: the resolver is invoked once per RunComposedPanelAsync run and threaded through to both RunComposedQueryAsync and RunAnnotationsAsync, so a panel's overlays share one ceiling instead of re-reading the config per query — matches the stated intent and the TheComposedQueryRunner_ResolvesItsDeadlinePerRun structural pin.

Fallback/clamp logic — ResolveComposedQuerySecondsAsync's value is int seconds ? seconds : 0 then through ClampComposeStatementTimeoutSeconds correctly lands on the 15s fallback for both a null scalar (unseeded/no-rows case) and a thrown 42P01 (pre-migration store), matching the three live-Postgres test cases.

Lite/Darling parity — No Lite changes here, and that's correctly justified rather than an oversight: DuckDBCommand has no 30s inherited default and may not honor CommandTimeout at all, so a parity edit there would assert a bound that can't be shown to bind anything. This isn't a case CONTRIBUTING.md's "Two-Store Parity" section requires coordination for either (no new interface members, no schema/migration rung).

Security — No new SQL string interpolation introduced; the composed-query runner already parameterizes via compiled.Parameters and that's untouched here. Nothing new touches secrets, file I/O, or process execution.

Minor/non-blocking nit: McpReadCommandTimeoutTests's s_storeReceiver regex ((?:postgres|_dataSource)\.CreateCommand\s*\(|new (?:Npgsql\.)?NpgsqlCommand\s*\(...,\s*connection\s*\)) has no word-boundary anchor before postgres/_dataSource/connection, so a hypothetical future variable like notpostgres or oldConnection could false-match or false-reject. Not a problem against any code in this diff, just worth tightening if the pin's receiver set ever grows.

Overall this is an unusually well-verified mechanical change — didn't find a correctness, parity, or security issue to flag.

LivePostgresCollectionHygieneTests reads only the 25 lines above a class
declaration when looking for [Collection("live-postgres")] or an "#1776
own-store" exemption. The marker was 26 lines up -- above the doc comment rather
than below it -- so it was invisible to the very rule it answers, and CI reported
the class as an undocumented shared-store consumer. The three existing own-store
classes all place theirs immediately above the declaration; this now does too,
and says why in the marker itself.

Reproduced locally by staging the throwaway xunit host inside the repo tree: the
rule resolves the source directory from AppContext.BaseDirectory, so a host built
in a scratch directory fails it for an unrelated reason -- which is why this
slipped through the first pass. Proven red by moving the marker back to 28 lines
up, which names the class as an offender again.
@claude

claude Bot commented Sep 4, 2026

Copy link
Copy Markdown

Reviewed. This is a well-scoped, Darling-only change (no T-SQL, no Lite touch — and Lite is explicitly and correctly excluded, since DuckDBCommand has no 30s default to inherit and may not honor CommandTimeout at all, so no parity drift here).

Verified in the working tree:

  • No dangling references to the removed McpCommandDeadlines.ComposedQuerySeconds constant — all call sites were migrated to ResolveComposedQuerySecondsAsync in commit 3.
  • Both callers of the composed-query runner (DarlingWebEndpoints.MapAll and DarlingMcpCustomViewTools) route through the one shared RunComposedPanelAsync, so the new per-run resolve is threaded identically to both surfaces — no divergence between the web and MCP paths.
  • Spot-checked ~15 of the ~30 touched reader files plus CustomViewStore.cs/DarlingWebEndpoints.cs: every CommandTimeout assignment sits immediately after its command construction and before the first Execute*Async, using the correct constant (McpCommandDeadlines.ReadSeconds for shipped-shape reads/writes, composedQuerySeconds for the operator-authored panel SQL).
  • ResolveComposedQuerySecondsAsync's query (SELECT compose_statement_timeout_seconds FROM config.config_service WHERE id = 1) is a hardcoded literal with no user input — no injection surface, consistent with the rest of the read surface.
  • The self-correcting commit history (2-5) is genuinely good engineering: each fixup was found by the PR's own red-first test variants (qualified type names, line-ending-sensitive anchors, a stale-cache footgun on the composed-query deadline, a CI hygiene-rule blind spot) rather than by review after the fact.

One soft note, not a blocker: ResolveComposedQuerySecondsAsync catches bare Exception (excluding OperationCanceledException) and falls back to 15s on any failure reading the config row — including e.g. a transient connection error, not just the documented "unseeded row" / "pre-V78 table absent" cases. That's a defensible choice (the subsequent composed query will hit the same underlying failure and surface it properly), and it's deliberately tested for the documented cases, but it's worth confirming that's the intended blast radius rather than only the two schema-shaped failures the doc comment calls out.

No missing-index DMV concerns, no secrets/credential handling changes, no SQL Server T-SQL touched.

…eal reach

Two review notes, both acted on.

The store-receiver allowlist had no boundary before postgres/_dataSource, so
`targetPostgres.CreateCommand(` matched `postgres\.CreateCommand` as a substring.
That is a false ACCEPT in the one guard whose job is to stop a monitored-target
command from taking a store deadline -- the mirror case, an unrecognised receiver
like `oldConnection`, already failed safe by asking for a decision. Boundaried
with a lookbehind, with notpostgres / targetPostgres / targetConnection pinned as
rejected and `this._dataSource` as accepted so the fix cannot over-reject.

ResolveComposedQuerySecondsAsync catches any non-cancellation failure, not only
the missing row and absent table its comment listed. That is intended: the
composed query runs on the same pool moments later and surfaces the same
underlying failure with its own error, so a deadline lookup should not be the
thing that fails the panel. Now said, rather than left to be inferred from two
examples.
@erikdarlingdata
erikdarlingdata merged commit c35cb15 into dev Sep 4, 2026
7 checks passed
erikdarlingdata added a commit that referenced this pull request Sep 4, 2026
…2951, #2952)

Two shapes of one defect, both surfaced while deriving SerialLoopSeconds.

The reload beacon assigned _lastConfigVersion on detecting a new version
and reloaded afterwards, so a reload whose read failed had already
recorded the version as applied: the operator's change stayed in the
store, absent from the process, and was never retried because the next
tick compared equal. The watermark now advances only inside
if (await ReloadFromStoreAsync(...)), with LoadViewAsync as the success
line since it is the atomic gate. The retry that creates is rate-limited
in StoreConfigProvider — first failure warns, the streak goes to Debug,
recovery reports once — so a persistently unreachable store does not emit
5,760 warnings a day. Not a retry policy; #2936 owns that for the
migrate path, which has no loop of its own.

ReadComposeStatementTimeoutAsync caught everything with a bare unlogged
catch and returned 15, which the caller wrote onto the login roles.
Since #2931 the client half of that backstop reads the same column live,
so the silent default desynced them. An unreadable value now omits the
two ALTER ROLE statements and reports a negative sentinel, so the roles
keep their last known-good horizon and #2918's gate re-asserts. A second
unfiltered catch in the same file, found by the pin's own file-wide
sweep, takes the filter too.

Neither has a Lite twin; that is now pinned rather than remembered.
@erikdarlingdata erikdarlingdata mentioned this pull request Sep 4, 2026
@erikdarlingdata
erikdarlingdata deleted the fix/2874-mcp-web-read-timeouts branch September 12, 2026 20:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant