Record concurrency audit findings

This commit is contained in:
2026-11-02 01:16:22 +00:00
parent d884ad731d
commit 1b39e71e81

View File

@@ -0,0 +1,169 @@
# Concurrency audit
Reviewed baseline: `d884ad7` on `main`, using Go 1.26.10 and Git 2.53.0 on
Linux. The findings below remain pending. Finding 1 overlaps an existing
correctness finding; finding 2 is an additional internal-helper weakness.
## Architecture and scope
The root facade coordinates candidate checking, publication, builds, preparation,
and hosted delivery. The CLI supplies profiles, credentials, output, and process
signals. Internal packages own the operation state and external effects;
`internal/forge` separates shared delivery policy from the Gitea adapter.
The `buildinfo` facade has a small, independent runtime implementation.
Most orchestration is sequential. Go settings owners, candidate-tree caches,
delivery result slices, and backend instances belong to individual operations.
The principal concurrency mechanisms are:
- `internal/process` uses context-driven subprocess cancellation, standard
library I/O goroutines, and `sync.Once` for piped-process waiting.
- `internal/git.BlobReader` serializes batch exchanges with a mutex and joins
each read's cancellation callback before releasing the exchange.
- `internal/gotool` serializes combined stdout/stderr capture and forwarding
with a mutex. Captured bytes are consumed after process completion.
- `internal/gitea` streams multipart uploads through an `io.Pipe` and a worker
goroutine, joining that worker before returning the borrowed content reader.
- `internal/runtimeinfo` caches fallback version discovery with
`sync.OnceValue`; the injected version is initialized at link time.
The review examined those ownership and shutdown paths, callback sequencing,
shared caches and output, build cleanup, publication guards/reconciliation,
delivery recovery, and signal propagation. Operation-local helpers such as the
Go settings owner and candidate tree are used sequentially by current callers;
their mutable maps alone do not establish a concurrency defect.
## Verification
The repeated, uncached race checks passed:
```sh
GOTOOLCHAIN=local GOPROXY=off GOSUMDB=off go test -race -count=5 \
./internal/process ./internal/git ./internal/gitea ./internal/runtimeinfo
```
The preceding correctness audit also passed the full ordinary and race-enabled
suites and compiled API/dependency checks. Production code and tests are
unchanged between those checks and this review's baseline.
Temporary race-enabled programs outside this checkout reproduced the two
findings below without race-detector diagnostics. Controlled Git substitutes
provided readiness and release signals; short-lived descendants exercised
inherited descriptors. Local HTTP/1.1 and HTTP/2 probes of early upload
rejection returned promptly and produced no additional finding. These probes
used local fixtures and placeholder credentials.
No data race or unjoined multipart-worker leak was identified in the reviewed
current orchestration paths. Race detection complements the liveness evidence;
a clean race run does not detect a blocked operation.
## 1. Medium: inherited descriptors can keep canceled operations and reader shutdown blocked
**Relevant code:** [process.Run, newCommand, and PipedProcess.Wait](../../../internal/process/process.go),
buffered Git execution in [internal/git/remote.go](../../../internal/git/remote.go),
and [BlobReader.Close](../../../internal/git/blob_reader.go), especially the
stdout drain at line 110 followed by the child wait. The owning cleanup runs
from [shared validation](../../../internal/validation/validation.go).
**Circumstances and mechanism:** A subprocess launches a descendant that
inherits stdout or stderr. Cancellation kills the immediate process, while
the descendant retains the descriptor. Buffered command execution waits for
its standard library I/O workers to receive EOF. The batched reader's normal
shutdown closes stdin, drains stdout to EOF, and then waits for the child.
Both paths can remain blocked after the governing context is canceled.
This is the same underlying issue recorded as finding 3 in the
[correctness audit](01-correctness.md#3-medium-inherited-process-pipes-can-defeat-cancellation-and-recovery-deadlines).
That audit reproduced delayed cancellation through public
`PrepareTaggedCheckout` using a simulated SSH transport. This concurrency pass
additionally exercised the explicitly owned batch-reader shutdown path.
**Additional reproduction:** A controlled `git cat-file --batch` substitute
returned a valid three-byte blob. After stdin reached EOF, it launched
`sleep 3` with inherited descriptors and exited. The reader's operation context
had a 500 ms deadline. The initial read succeeded, but `BlobReader.Close`
returned only after approximately 3.005 seconds, with the deadline error then
preserved. The probe ran under the race detector and completed after its
descendant exited. A descendant retaining the descriptors indefinitely can
keep this shutdown waiting indefinitely.
**Consequences:** Cancellation can leave validation or preparation occupied
beyond its deadline, delay temporary-output cleanup, and prevent publication
reconciliation from returning its documented bounded unresolved outcome.
`BlobReader.Close` holds the reader mutex during its drain, so other readers
and repeated closes also wait for that shutdown to finish.
**Potential remediation:** Give subprocess I/O a bounded cancellation/shutdown
path. Use the standard `exec.Cmd.WaitDelay` mechanism where applicable, and
verify that explicitly owned stdin/stdout pipes also close when their operation
is canceled. Coordinate cancellation callbacks, pipe closure, and `Wait` so
the child and workers are joined before the owning operation returns. Apply
the same review to direct subprocess calls in
[internal/build](../../../internal/build/build.go). Preserve existing context
error identity and publication uncertainty semantics.
Regression coverage should include a ready descendant retaining a descriptor
during both buffered command execution and batch-reader cleanup. The existing
single-process cancellation tests pass. This finding calls for completing the
selected cancellation behavior; it does not require a general service for
supervising every descendant process. Treat this and correctness finding 3 as
one remediation unit.
## 2. Low: a canceled queued blob read can later abort a healthy session
**Relevant code:** [BlobReader.Read](../../../internal/git/blob_reader.go),
lines 44–88. It checks the request context before acquiring `reader.mu`, then
starts or reuses the session and registers the cancellation callback after
acquiring the mutex. It does not recheck the request context between those
steps. `abortLocked` records any exchange cancellation as the reader's terminal
error.
**Circumstances:** Two goroutines call `Read` on one reader with independent
request contexts. The first holds the mutex during a slow exchange. The
second passes its initial context check, waits for the mutex, and is canceled
while waiting. Its cancellation callback is not registered during that wait.
Once the first exchange finishes, the second acquires the mutex and proceeds
with an already-canceled context. Its newly registered `context.AfterFunc`
can immediately cancel the healthy child and close its pipes. The read then
records a terminal cancellation, affecting subsequent uncanceled requests.
**Reproduction:** A controlled batch process signaled when the first exchange
was active and waited for an external release signal. An observed request
context established that the second read had passed its initial context check
before cancellation. The canceled request remained blocked during a 100 ms
observation window. After releasing the first exchange:
```text
first read error: <nil>
second read canceled: true
next uncanceled read inherits cancellation: true
```
The race-enabled probe produced no data-race diagnostic. The defect concerns
the ordering of cancellation and session ownership.
**Consequences and current reach:** Queued canceled work can wait behind an
unrelated exchange and then terminate its otherwise usable shared reader.
The helper advertises serialized reads and implements mutex-based sharing,
but current candidate validation calls it sequentially. This is an internal
concurrent-use weakness; the audit did not identify a current public operation
that issues these overlapping reads on one reader.
**Potential remediation:** At minimum, recheck `ctx.Err()` immediately after
acquiring the mutex and before starting an exchange or registering a callback
that can cancel the child. This prevents a request canceled while queued from
changing the healthy session. If queued reads should return promptly on their
own cancellation, use cancellation-aware acquisition of the exchange slot.
Keep current candidate-tree traversal sequential while assessing that need;
this finding supplies no reason to parallelize validation or add locks to its
operation-local caches. A focused helper regression can verify the healthy
first exchange and a later uncanceled read after cancellation of queued work.
## Recommendation
Complete the existing subprocess-cancellation remediation first, including the
explicit batch-reader shutdown path. Address the queued-read context recheck
as a small internal correctness improvement; select cancellation-aware queue
waiting according to the helper's intended concurrent-use contract. Retain the
current operation-local architecture and joined worker lifetimes.