-
Notifications
You must be signed in to change notification settings - Fork 481
fix: detach audit/audit-diff subprocess contexts from MCP gateway 60s deadline #49061
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
7d96d94
8966828
fb6ab49
e1d0ae2
2fd9d52
13b6a49
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,44 @@ | ||
| # ADR-49061: Detach Audit/Audit-Diff Subprocess Contexts from MCP Gateway Deadline | ||
|
|
||
| **Date**: 2026-07-30 | ||
| **Status**: Draft | ||
| **Deciders**: pelikhan, copilot-swe-agent | ||
|
|
||
| --- | ||
|
|
||
| ### Context | ||
|
|
||
| The MCP gateway imposes a hard per-tool RPC deadline (typically 60 seconds) on every inbound request context. The `audit` and `audit-diff` MCP tools passed this context directly to `exec.CommandContext`, which binds the subprocess lifetime to the gateway's deadline. As a result, every `audit` and `audit-diff` call failed with `context deadline exceeded` at exactly ~60 seconds, regardless of whether the actual workload could complete in time. The `logs` tool had already been fixed with the correct detach-and-own-timeout pattern; `audit` and `audit-diff` were inconsistently missing it. | ||
|
|
||
| ### Decision | ||
|
|
||
| We will detach the `audit` and `audit-diff` subprocess contexts from the MCP gateway's RPC deadline by applying `context.WithoutCancel(ctx)` before creating a tool-owned `context.WithTimeout`. Context values (e.g. trace IDs) are preserved. A goroutine watcher selectively forwards only `context.Canceled` (explicit client disconnect) to the subprocess context — it never forwards `context.DeadlineExceeded` from the gateway, ensuring the subprocess can run for its full allotted time. This is identical to the pattern already used for the `logs` tool. | ||
|
|
||
| ### Alternatives Considered | ||
|
|
||
| #### Alternative 1: Increase the MCP Gateway's RPC Deadline | ||
|
|
||
| Raise the gateway's per-tool timeout to 5+ minutes for all tools. This would prevent the 60 s kill but would require changes to gateway infrastructure/configuration outside this codebase. It also applies a blanket increase to all tools, including fast ones, where a 60 s deadline is a reasonable guard against runaway subprocesses. | ||
|
|
||
| #### Alternative 2: Redesign Tools for Incremental/Paginated Output | ||
|
|
||
| Break audit operations into smaller, faster incremental requests that each complete within the 60 s window. This would be a deeper protocol redesign with higher implementation complexity and would require changes on both the tool and client sides. It does not address the root cause of the subprocess-context coupling. | ||
|
|
||
| ### Consequences | ||
|
|
||
| #### Positive | ||
| - `audit` and `audit-diff` tools no longer fail with `context deadline exceeded` after 60 seconds; legitimate long-running audits can complete within their 5-minute subprocess timeout. | ||
| - Client disconnects (explicit `context.Canceled`) still propagate promptly to clean up subprocesses, preventing orphaned processes. | ||
| - The pattern is now consistent across all three long-running privileged tools (`logs`, `audit`, `audit-diff`). | ||
|
|
||
| #### Negative | ||
| - A subprocess that has been detached from the gateway context can continue running for up to 5 minutes even if the gateway drops the connection for a reason other than explicit client cancellation (e.g. gateway restart, proxy timeout). Only `context.Canceled` is forwarded; `context.DeadlineExceeded` is intentionally suppressed. | ||
| - The 5-minute timeout constants (`defaultMCPAuditTimeoutMinutes`, `defaultMCPAuditDiffTimeoutMinutes`) are both set to the same value; if operational experience shows `audit-diff` needs more headroom than `audit` (it downloads artifacts for multiple runs), both constants must be updated independently. | ||
|
|
||
| #### Neutral | ||
| - Two goroutine watchers are introduced (one per tool), which is the same pattern already in use for `logs`. Each goroutine is bounded in lifetime by the subprocess context. | ||
| - Regression tests modeled on the existing `TestLogsToolSubprocessContextIgnoresGatewayDeadline` are added for both new tools. | ||
|
|
||
| --- | ||
|
|
||
| *ADR created by [adr-writer agent]. Review and finalize before changing status from Draft to Accepted.* |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -32,6 +32,16 @@ const ( | |
| // single long-running request could otherwise block all callers for an arbitrarily | ||
| // long time. | ||
| maxMCPLogsSubprocessTimeoutMinutes = 60 | ||
|
|
||
| // defaultMCPAuditTimeoutMinutes is the default subprocess timeout for the audit | ||
| // tool. Auditing a single run typically takes 5–30 s, but large runs with many | ||
| // artifact sets can take longer. 5 minutes gives ample headroom while still | ||
| // bounding the subprocess lifetime. | ||
| defaultMCPAuditTimeoutMinutes = 5 | ||
| // defaultMCPAuditDiffTimeoutMinutes is the default subprocess timeout for the | ||
| // audit-diff tool. 5 minutes gives ample headroom for the artifact-download | ||
| // and diff steps while still bounding the subprocess lifetime. | ||
| defaultMCPAuditDiffTimeoutMinutes = 5 | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [/diagnosing-bugs] The comment says 💡 Suggested fixEither update the comment to remove the "more headroom" claim, or raise the constant to reflect the intent: // audit-diff downloads artifacts for all referenced runs before computing the diff,
// so it needs more headroom than a single-run audit.
defaultMCPAuditDiffTimeoutMinutes = 10Mismatched comments and values erode trust in constants over time. @copilot please address this. |
||
| ) | ||
|
|
||
| // appendRepoFlagFromEnv appends "--repo <owner/repo>" to args when GITHUB_REPOSITORY | ||
|
|
@@ -47,6 +57,43 @@ func appendRepoFlagFromEnv(args []string) []string { | |
| return args | ||
| } | ||
|
|
||
| // newMCPSubprocessContext creates a subprocess context that is detached from the | ||
| // MCP gateway's per-request deadline. The gateway imposes a short RPC deadline | ||
| // (typically 60 s) on the request context; passing that context directly to | ||
| // exec.CommandContext would kill long-running subprocesses prematurely. | ||
| // | ||
| // The returned context is rooted at context.Background() (values preserved, | ||
| // gateway deadline stripped) and carries only the caller-specified timeout. | ||
| // A goroutine is started to forward explicit client cancellations | ||
| // (context.Canceled) to the subprocess while ignoring DeadlineExceeded from | ||
| // the gateway. | ||
| // | ||
| // toolName is used solely in the panic-recovery log message. | ||
| func newMCPSubprocessContext(ctx context.Context, timeout time.Duration, toolName string) (context.Context, context.CancelFunc) { | ||
| subCtx, subCancel := context.WithTimeout( | ||
| context.WithoutCancel(ctx), | ||
| timeout, | ||
| ) | ||
| go func() { | ||
| defer func() { | ||
| if r := recover(); r != nil { | ||
| mcpLog.Printf("Panic in MCP %s context-watcher goroutine (recovered): %v", toolName, r) | ||
| } | ||
| }() | ||
| // Only forward explicit cancellations (context.Canceled); do NOT propagate | ||
| // context.DeadlineExceeded from the MCP gateway — that would kill the subprocess | ||
| // at the gateway's 60 s RPC deadline and defeat the purpose of this fix. | ||
| select { | ||
| case <-ctx.Done(): | ||
| if ctx.Err() == context.Canceled { | ||
| subCancel() // propagate client disconnect to subprocess | ||
| } | ||
| case <-subCtx.Done(): // subprocess timed out or caller already cancelled | ||
| } | ||
| }() | ||
| return subCtx, subCancel | ||
| } | ||
|
|
||
| // logsArgs holds the input parameters for the logs tool. | ||
| type logsArgs struct { | ||
| WorkflowName string `json:"workflow_name,omitempty" jsonschema:"Name of the workflow to download logs for (empty for all)"` | ||
|
|
@@ -486,11 +533,15 @@ Multi-run diff returns JSON describing changes between the base and each compari | |
|
|
||
| notifyProgress(ctx, req, 0, 100, "Downloading audit artifacts...") | ||
|
|
||
| // Detach from the gateway's per-tool RPC deadline; see newMCPSubprocessContext. | ||
| subCtx, subCancel := newMCPSubprocessContext(ctx, time.Duration(defaultMCPAuditTimeoutMinutes)*time.Minute, "audit") | ||
| defer subCancel() | ||
|
|
||
| // Execute the CLI command. | ||
| // Use separate stdout/stderr capture instead of CombinedOutput because: | ||
| // - Stdout contains JSON output (--json flag) | ||
| // - Stderr contains console messages and debug logs that shouldn't be mixed with JSON | ||
| stdout, err := runMCPExecOutput(ctx, execCmd, cmdArgs...) | ||
| stdout, err := runMCPExecOutput(subCtx, execCmd, cmdArgs...) | ||
|
|
||
| // The audit command outputs JSON to stdout when --json flag is used. | ||
| // If the command fails, we need to provide detailed error information. | ||
|
|
@@ -619,7 +670,11 @@ Returns JSON describing the differences between the base run and each comparison | |
|
|
||
| notifyProgress(ctx, req, 0, 100, "Downloading artifacts for diff...") | ||
|
|
||
| stdout, err := runMCPExecOutput(ctx, execCmd, cmdArgs...) | ||
| // Detach from the gateway's per-tool RPC deadline; see newMCPSubprocessContext. | ||
| subCtx, subCancel := newMCPSubprocessContext(ctx, time.Duration(defaultMCPAuditDiffTimeoutMinutes)*time.Minute, "audit-diff") | ||
| defer subCancel() | ||
|
|
||
| stdout, err := runMCPExecOutput(subCtx, execCmd, cmdArgs...) | ||
| outputStr := string(stdout) | ||
|
|
||
| if err != nil { | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixed 5-minute timeout regardless of how many runs are audited/diffed — this can reintroduce the same premature-timeout bug the PR is fixing, just at a different threshold.
💡 Details
auditacceptsrun_ids_or_urls(multiple runs) andaudit-diffacceptscompare_run_ids(multiple comparison runs), but both use a flatdefaultMCPAuditTimeoutMinutes = 5/defaultMCPAuditDiffTimeoutMinutes = 5constant. The siblinglogstool in this same file already solves this by scaling its timeout witheffectiveMCPLogsToolTimeoutMinutes(...)based on request size. Auditing/diffing many runs — each requiring its own artifact download — can legitimately exceed 5 minutes, causing the exactcontext deadline exceededsymptom this PR is fixing, just moved from 60s to 300s.Suggested fix: scale the timeout by
len(runItems)/len(args.CompareRunIDs)similar to the logs tool per-count scaling, or expose a configurable timeout parameter likelogsdoes.