Files
commandkit/docs/roadmap/audit/04-concurrency.md

179 lines
11 KiB
Markdown
Raw Normal View History

2026-11-07 12:58:48 +00:00
# Concurrency audit
Status: three relevant P2 findings: one new cancellation defect and two
reconfirmed findings from the functional correctness audit. Reviewed source:
`2ea26afcc5ae691152e80d2fa5ee4d572d390a95`.
## Architecture, contracts, and scope
The [internal overview](../../internal/overview.md) inventories the implemented
owners. Core captures declarations and routing metadata, builds fresh backend
parse state per invocation, prepares typed inputs, and coordinates invocation
reporting and outcomes. ckconfig owns configuration scope/comparison coordination;
ckprompt owns PromptKit request/inspection coordination and sequential aggregate
smoke tests. Applications own loaders, builders, runtime dependencies, contexts,
and executable lifecycle. Upstream libraries own their domain operations.
The [reporting contract](../../consumers/guide.md#invocation-reporting) and
[Reporter GoDoc](../../../reporter.go) explicitly permit concurrent operations on
one invocation's reporter. One reporting-state mutex protects visibility,
deferred messages, destination observations, and writes. Managed output writes
acquire it; rendering callbacks use a coordinated writer while the rendering
operation holds it. Rendering callbacks must not reenter that reporter, and
their supplied writer is valid only while the callback runs. Concurrent message
submission order is unspecified. Reporter handles close before Execute returns;
caller-owned writers are not closed.
Concurrent Execute/Register access to one App is not established by the current
contracts; [concurrent dispatch remains separate future work](../future.md#other-future-proposals).
The audit did not infer that guarantee from independent invocation state or
declaration reuse. Application callback objects and factory results retain their
own ownership and synchronization responsibilities under the
[architecture policy](../../policy/architecture.md#handler-inputs-and-invocation-state).
Static review covered locks and state access, reporter acceptance/finalization,
callback and writer lifetimes, declaration/snapshot ownership, integration input
and shared-state lifetimes, cancellation checkpoints, comparison/aggregate
sequencing, and maintained examples and release launchers. CommandKit's runtime
code starts no goroutines and owns no channels or worker pools. Integration
callbacks run synchronously; aggregate scopes, comparison sides, and smoke kinds
are coordinated sequentially. Release launchers use exec to delegate to ReleaseKit.
Application/upstream workers and subprocess internals remain with their owners.
## Validation evidence
Codebase-memory-mcp symbol/source searches and call tracing were supplemented
with repository-wide searches for synchronization, goroutines, channels, and
cancellation. The indexed Go source matches this revision. Validation used Go
1.27.0, cached dependencies, and no live services or credentials:
- The aligned current-source workspace suite passed with race detection and
fresh test execution:
`GOTOOLCHAIN=local go test -race -count=1 -mod=readonly ./... ./integrations/ckconfig/... ./integrations/ckprompt/...`.
This includes all three modules and their maintained examples. The optional
interactive shell checks remain opt-in and were not enabled in this pass.
- A temporary external consumer run with `go run -race` reproduced N1 through
the public ckprompt/core APIs. It canceled the context from the progress
destination's writer without reentering the reporter.
- Existing temporary external probes using local replacements for all three
CommandKit modules reproduced N2 and N3 in three consecutive runs each with
`go test -race -count=3`. Their functional assertions failed as described
below. No data-race report was emitted.
No production files or repository tests were changed. These failures concern
ordering and cancellation accounting; memory-race detection alone does not
establish that an accepted queue item is delivered or a stopped aggregate fails.
## N1 — P2: Cancellation can stop aggregate smoke testing with a success outcome
**Code:** [smoke_command.go](../../../integrations/ckprompt/smoke_command.go),
runSmokeAggregate lines 159–176 and final outcome selection at 183–187;
queuePromptDiagnostics in [command.go](../../../integrations/ckprompt/command.go),
lines 586–603; core's Progress delivery in [reporter.go](../../../reporter.go).
**Circumstances:** a smoke callback returns a report without error. The first
post-callback context check sees no cancellation. Cancellation becomes visible
while diagnostics are presented, or asynchronously before the later checkpoint
at line 175. That checkpoint stops the loop, but does not retain its error.
Progress diagnostics are delivered immediately, so this window can be exercised
deterministically with a valid progress writer that cancels the context.
**Observed behavior:** a temporary consumer invoked
`promptkit test all --progress --format=json`. Its first callback returned a
completed reachability report and a progress diagnostic. The diagnostic writer
canceled the context and accepted all bytes. Only one callback ran; the JSON
report contained one successful entry with no error. Execute nevertheless
returned Kind success, status 0, and nil Cause. Rendering and writing errors
were nil. The other three requested kinds were never attempted.
A second probe returned a provider error from that callback: status was 1, but
errors.Is(outcome.Cause, context.Canceled) was still false after the same stop.
**Expected behavior and consequences:** the
[aggregate smoke contract](../../cli.md#promptkit-smoke-testing) requires all four
callbacks to return without error for status 0 and preserves cancellation causes
when cancellation stops the sequence. A partial aggregate stopped by cancellation
must not report successful completion. Automation can otherwise accept an
incomplete aggregate, and callers cannot detect the cancellation through
errors.Is on the returned cause. If an operation error already exists, the same
window can omit cancellation from its joined causes.
**Potential remediation:** when the later checkpoint uses cancellation to omit
remaining kinds, retain that error in the aggregate cause before leaving the
loop. Deduplicate an already retained cancellation as appropriate. Preserve
completed reports, prior operation errors/explanations, and attempted-kind order;
do not invent a report for an unattempted kind. Moving only the earlier check
leaves another interval in which cancellation can be observed and discarded.
This does not require treating every cancellation after completed report delivery
as a new failure. Coverage should distinguish cancellation in the provider,
during immediate progress presentation, and between aggregate operations.
## N2 — P2: Deferred-message acceptance remains open after the final batch detaches
This is the same issue as
[C2 in the correctness audit](01-correctness.md#c2--p2-finalization-can-discard-a-successfully-queued-deferred-message),
reconfirmed here rather than a separate remediation item.
**Code:** [reporter.go](../../../reporter.go), DeferredReporter.enqueue lines
254–279 and Reporter.finalize lines 282–325.
**Circumstances:** finalization detaches state.deferred and unlocks between
deliveries. Another goroutine can acquire the mutex during that flush, append a
visible message to the now-empty queue, and receive nil from its submission.
The reporter has not yet set closed. The finalizer drains only the detached
batch, then clears the new queue when closing.
**Consequences and evidence:** the accepted message silently disappears, while
Execute can return success and mark diagnostic delivery complete. The probe
used one invocation, a briefly pausing diagnostic destination, and a goroutine
submitting AtEnd().Warn during the flush. All three race-enabled runs accepted
and lost the message. There was no unsynchronized memory access or callback
reentry; the defect is the queue's acceptance/finalization ordering.
**Potential remediation:** atomically close deferred acceptance when detaching
the final batch, rejecting later submissions explicitly, or drain every accepted
item before closure. Keep finalizer-owned delivery possible after acceptance
closes; setting the reporter's existing closed flag too early would also reject
its internal delivery. Preserve message visibility, ordering, and error evidence.
Goroutines created by an application remain application-owned; this finding
concerns an operation that already returned successful acceptance.
## N3 — P2: Buffered configuration-show cancellation becomes a presentation failure
This is the same issue as
[C4 in the correctness audit](01-correctness.md#c4--p2-show-preparation-labels-ordinary-cancellation-as-a-rendering-error),
reconfirmed here rather than a separate remediation item.
**Code:** [show.go](../../../integrations/ckconfig/show.go), runConfigShow lines
69–95, interacting with core Reporter.RenderOutput in
[reporter.go](../../../reporter.go).
**Circumstances:** cancellation becomes visible between buffered effective
document conversions. The checkpoint inside RenderOutput returns ctx.Err as
the rendering callback's error. Core records it as Output.RenderError before
ckconfig returns cancellation as the operation cause.
**Consequences and evidence:** normal cancellation is reported as both an
operation failure and an incomplete output/rendering failure. A two-scope probe
acquired both views; the first native writer emitted valid safe YAML, canceled
the context, and returned nil. The second writer was skipped and no report
bytes were delivered. All three race-enabled runs retained context.Canceled as
Cause but also incorrectly recorded it as Output.RenderError, with no writer
error. This can mislead callers distinguishing interrupted preparation from
encoder or output failures.
**Potential remediation:** retain preparation cancellation separately from the
rendering callback's error, following renderPreparedConfigReport in
[operations.go](../../../integrations/ckconfig/operations.go). Preserve real
native-writing, conversion, and destination errors independently, and keep an
incomplete aggregate report from being delivered. Retain the selected distinction
between cancellation before delivery and cancellation after delivery has started.
## Assessment
N1 is new. N2 and N3 remain open in the correctness audit and should be tracked
as the same underlying issues. No data race, supported-path deadlock,
goroutine leak, or channel-lifecycle defect was established in the reviewed
CommandKit code. The passing race suite supports the exercised synchronization
paths; the three findings demonstrate why lifecycle and cancellation reasoning
are also necessary.