Skip to content

refactor: clone ParsedPolicy in processPolicy to prevent shared-state races - #7794

Merged
ycombinator merged 6 commits into
elastic:mainfrom
ycombinator:refactor/clone-parsed-policy-in-process-policy
Sep 10, 2026
Merged

refactor: clone ParsedPolicy in processPolicy to prevent shared-state races#7794
ycombinator merged 6 commits into
elastic:mainfrom
ycombinator:refactor/clone-parsed-policy-in-process-policy

Conversation

@ycombinator

@ycombinator ycombinator commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Adds ParsedPolicy.Clone() which returns a fully independent copy — every slice, map, and pointer field gets its own backing storage: SecretKeys and Policy.Namespaces via slices.Clone; Inputs, Roles (including RoleT.Raw bytes), and Outputs (including Output.Role pointers) deep-copied element by element; Agent and Fleet via maps.Clone; Policy.Data via model.ClonePolicyData.
  • Also updates model.ClonePolicyData to clone its Agent and Fleet maps, OutputPermissions bytes, and OTel section maps via cloneOTelSection — necessary because prepareOTelExporters mutates per-component maps in-place; nil Inputs is preserved as nil.
  • Moves the clone to the dispatch site in monitor.go: s.ch <- policy.pp.Clone() instead of &policy.pp. Each channel send now transfers an exclusively-owned *ParsedPolicy to its subscriber, so processPolicy receives a copy it already owns and can mutate freely — no implicit contract that callers must clone.
  • processPolicy is unchanged in behaviour; the clone just moves upstream to the natural ownership-transfer boundary.
  • Adds TestParsedPolicyCloneIsolation to verify that mutating each field of a clone does not affect the original.

References

Relates #7739

🤖 Generated with Claude Code

@ycombinator
ycombinator requested a review from a team as a code owner September 10, 2026 12:33
@ycombinator ycombinator added bug Something isn't working Team:Elastic-Agent-Control-Plane Label for the Agent Control Plane team labels Sep 10, 2026
@ycombinator
ycombinator requested review from lorienhu and swiatekm and a lite review from Copilot September 10, 2026 12:33
@ycombinator ycombinator added bug Something isn't working Team:Elastic-Agent-Control-Plane Label for the Agent Control Plane team labels Sep 10, 2026
@mergify

mergify Bot commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

This pull request does not have a backport label. Could you fix it @ycombinator? 🙏
To fixup this pull request, you need to add the backport labels for the needed
branches, such as:

  • backport-./d./d is the label to automatically backport to the 8./d branch. /d is the digit
  • backport-active-all is the label that automatically backports to all active branches.
  • backport-active-8 is the label that automatically backports to all active minor branches for the 8 major.
  • backport-active-9 is the label that automatically backports to all active minor branches for the 9 major.

@ycombinator
ycombinator force-pushed the refactor/clone-parsed-policy-in-process-policy branch from b4dd4ec to 9541e07 Compare September 10, 2026 12:39

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

ParsedPolicy.Clone() currently copies pp.Policy without cloning Policy.Namespaces, so the implementation does not fully match its stated “independent backing storage” guarantee and may still allow shared-slice races if that field is ever mutated.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

This PR refactors policy handling to eliminate shared-state mutation in processPolicy by introducing a ParsedPolicy.Clone() method and switching processPolicy to operate on a per-call cloned ParsedPolicy, preventing cross-agent goroutines from racing when preparing policy actions.

Changes:

  • Added (*policy.ParsedPolicy).Clone() to copy ParsedPolicy slice/map fields into independent backing storage.
  • Updated processPolicy to clone the ParsedPolicy up front and mutate pp.SecretKeys on the clone (removing the narrower secretKeys := slices.Clone(...) approach).
File summaries
File Description
internal/pkg/policy/parsed_policy.go Adds ParsedPolicy.Clone() to make per-goroutine copies of mutable slice/map fields.
internal/pkg/api/handleCheckin.go Updates processPolicy to use pp = pp.Clone() and mutate secret key paths on the cloned policy instance.
Review details
  • Files reviewed: 2/2 changed files
  • Comments generated: 1
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread internal/pkg/policy/parsed_policy.go
Copilot AI review requested due to automatic review settings September 10, 2026 12:39
@ycombinator
ycombinator force-pushed the refactor/clone-parsed-policy-in-process-policy branch from 9541e07 to 8ef7eee Compare September 10, 2026 12:42

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

ParsedPolicy.Clone() currently still shares some backing storage/pointers despite claiming full isolation, which weakens the race-safety contract this PR is relying on.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Review details
  • Files reviewed: 2/2 changed files
  • Comments generated: 1
  • Review effort level: Lite

Comment thread internal/pkg/policy/parsed_policy.go Outdated
Copilot AI review requested due to automatic review settings September 10, 2026 12:43

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

ParsedPolicy.Clone() still shallow-copies mutable nested maps (notably OTel exporter config maps), so processPolicy can still race when it mutates those configs.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Review details

Suppressed comments (1)

internal/pkg/policy/parsed_policy.go:76

  • Clone() currently relies on model.ClonePolicyData, which shallow-clones OTel maps like Policy.Data.Exporters (only the outer map). prepareOTelExporters mutates each exporter config map in-place, so if exporter entries are map[string]any (as expected), those inner maps can still be shared across goroutines and race. Consider deep-cloning the exporter config maps (and other mutable Policy.Data maps if needed) inside Clone() to make the copy actually mutation-safe.
	clone.Policy.Data = model.ClonePolicyData(pp.Policy.Data)
	clone.Policy.Namespaces = slices.Clone(pp.Policy.Namespaces)
  • Files reviewed: 2/2 changed files
  • Comments generated: 2
  • Review effort level: Lite

Comment thread internal/pkg/policy/parsed_policy.go Outdated
Comment thread internal/pkg/policy/parsed_policy.go
Copilot AI review requested due to automatic review settings September 10, 2026 12:50
@ycombinator
ycombinator force-pushed the refactor/clone-parsed-policy-in-process-policy branch from 8ef7eee to 09154ab Compare September 10, 2026 12:50
@ycombinator
ycombinator force-pushed the refactor/clone-parsed-policy-in-process-policy branch from 09154ab to fac89aa Compare September 10, 2026 12:54
@github-actions

This comment has been minimized.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

The new cloning still leaves some mutable policy data potentially shared (and lacks focused unit coverage for clone-independence), which can undermine the intended race-prevention guarantee.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Review details

Suppressed comments (1)

internal/pkg/policy/parsed_policy.go:64

  • ParsedPolicy.Clone is new concurrency-safety plumbing for processPolicy, but there are no unit tests validating that the clone is actually mutation-independent (e.g., SecretKeys slice and nested policy data such as OTel exporter configs). Adding a focused test would help prevent regressions in this area.
// Clone returns a copy of pp where every slice, map, and pointer field has its
// own backing storage, so concurrent goroutines can mutate their copy without racing.
func (pp *ParsedPolicy) Clone() *ParsedPolicy {
  • Files reviewed: 4/4 changed files
  • Comments generated: 2
  • Review effort level: Lite

Comment thread internal/pkg/policy/parsed_policy.go
Comment thread internal/pkg/model/ext.go Outdated
@github-actions

This comment has been minimized.

@ycombinator
ycombinator force-pushed the refactor/clone-parsed-policy-in-process-policy branch from fac89aa to d039361 Compare September 10, 2026 12:59
maps.Clone on Agent and Fleet, and per-element maps.Clone on Inputs,
were shallow clones that implied deeper isolation than they provided.
processPolicy never accesses Agent or Fleet, and never mutates Inputs
elements — only overwrites the slice header via Policy.Data.Inputs.

Replace with direct assignment (Agent, Fleet) and slices.Clone (Inputs)
and document the invariant. Remove the now-incorrect Agent isolation
assertion from TestParsedPolicyCloneIsolation.

Also drops the unused "maps" import.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
Copilot AI review requested due to automatic review settings September 10, 2026 18:55

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

ParsedPolicy.Clone() currently leaves Agent/Fleet pointing at the original maps (while Policy.Data.Agent/Fleet are cloned), breaking aliasing invariants and retaining shared mutable state that undermines the intended ownership-transfer semantics.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Review details
  • Files reviewed: 6/6 changed files
  • Comments generated: 1
  • Review effort level: Lite

Comment thread internal/pkg/policy/parsed_policy.go
NewParsedPolicy assigns pp.Agent = p.Data.Agent and pp.Fleet =
p.Data.Fleet, so the two fields alias the same map. Clone() was
leaving clone.Agent/Fleet pointing at the original's maps while
clone.Policy.Data had freshly-cloned maps from ClonePolicyData,
breaking the invariant.

Set clone.Agent and clone.Fleet from the cloned Policy.Data after
ClonePolicyData runs, preserving the alias on the clone side.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
Copilot AI review requested due to automatic review settings September 10, 2026 19:13

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

The new clone-isolation test currently doesn’t reliably exercise nested-map sharing for outputs and includes a misleading comment, weakening regression protection for the concurrency fix.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Review details

Suppressed comments (1)

internal/pkg/policy/parsed_policy_test.go:131

  • The nested-map isolation assertion can be a no-op with the current embedded test_policy.json because its outputs don't include an existing nested map (e.g., no "ssl" object). In that case, the test only proves top-level output-map isolation and would still pass if ClonePolicyData shallow-cloned nested maps/slices. Seed a nested map in the original before cloning so the test reliably detects nested sharing regressions.
	// Seed a known secret key so we can detect cross-contamination.
	original.SecretKeys = []string{"outputs.default.token"}

	clone := original.Clone()

  • Files reviewed: 6/6 changed files
  • Comments generated: 1
  • Review effort level: Lite

Comment thread internal/pkg/policy/parsed_policy_test.go Outdated
After 0f62096 clone.Agent is derived from the cloned Policy.Data
(not a shared reference), so the stale comment is removed and the
top-level isolation assertion is restored.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
Copilot AI review requested due to automatic review settings September 10, 2026 19:44

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Approval recommended

The changes correctly establish per-subscriber ownership of mutable policy state, and include targeted tests to prevent regressions of the reported race.

Review details
  • Files reviewed: 6/6 changed files
  • Comments generated: 0 new
  • Review effort level: Lite

@ycombinator
ycombinator enabled auto-merge (squash) September 10, 2026 20:24
@ycombinator ycombinator added the backport-active-all Automated backport with mergify to all the active branches label Sep 10, 2026
@ycombinator
ycombinator merged commit c907276 into elastic:main Sep 10, 2026
13 checks passed
@github-actions

Copy link
Copy Markdown
Contributor

@Mergifyio backport 9.5 9.4 8.19

@mergify

mergify Bot commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

backport 9.5 9.4 8.19

✅ Backports have been created

Details

Cherry-pick of c907276 has failed:

On branch mergify/bp/9.5/pr-7794
Your branch is up to date with 'origin/9.5'.

You are currently cherry-picking commit c907276.
  (fix conflicts and run "git cherry-pick --continue")
  (use "git cherry-pick --skip" to skip this patch)
  (use "git cherry-pick --abort" to cancel the cherry-pick operation)

Changes to be committed:
	modified:   internal/pkg/model/ext.go
	modified:   internal/pkg/policy/monitor.go
	modified:   internal/pkg/policy/parsed_policy.go
	modified:   internal/pkg/policy/parsed_policy_test.go

Unmerged paths:
  (use "git add <file>..." to mark resolution)
	both modified:   internal/pkg/api/handleCheckin.go
	both modified:   internal/pkg/api/handleCheckin_test.go

To fix up this pull request, you can check it out locally. See documentation: https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/reviewing-changes-in-pull-requests/checking-out-pull-requests-locally

Cherry-pick of c907276 has failed:

On branch mergify/bp/9.4/pr-7794
Your branch is up to date with 'origin/9.4'.

You are currently cherry-picking commit c907276.
  (fix conflicts and run "git cherry-pick --continue")
  (use "git cherry-pick --skip" to skip this patch)
  (use "git cherry-pick --abort" to cancel the cherry-pick operation)

Changes to be committed:
	modified:   internal/pkg/policy/monitor.go
	modified:   internal/pkg/policy/parsed_policy.go
	modified:   internal/pkg/policy/parsed_policy_test.go

Unmerged paths:
  (use "git add <file>..." to mark resolution)
	both modified:   internal/pkg/api/handleCheckin.go
	both modified:   internal/pkg/api/handleCheckin_test.go
	both modified:   internal/pkg/model/ext.go

To fix up this pull request, you can check it out locally. See documentation: https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/reviewing-changes-in-pull-requests/checking-out-pull-requests-locally

Cherry-pick of c907276 has failed:

On branch mergify/bp/8.19/pr-7794
Your branch is up to date with 'origin/8.19'.

You are currently cherry-picking commit c907276.
  (fix conflicts and run "git cherry-pick --continue")
  (use "git cherry-pick --skip" to skip this patch)
  (use "git cherry-pick --abort" to cancel the cherry-pick operation)

Changes to be committed:
	modified:   internal/pkg/policy/parsed_policy.go

Unmerged paths:
  (use "git add <file>..." to mark resolution)
	both modified:   internal/pkg/api/handleCheckin.go
	both modified:   internal/pkg/api/handleCheckin_test.go
	both modified:   internal/pkg/model/ext.go
	both modified:   internal/pkg/policy/monitor.go
	both modified:   internal/pkg/policy/parsed_policy_test.go

To fix up this pull request, you can check it out locally. See documentation: https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/reviewing-changes-in-pull-requests/checking-out-pull-requests-locally

@ycombinator
ycombinator deleted the refactor/clone-parsed-policy-in-process-policy branch September 10, 2026 21:27
ycombinator added a commit that referenced this pull request Sep 10, 2026
Conflict markers from the Mergify auto-backport of #7794 are resolved:
- handleCheckin.go: use pp.Policy.Data.Outputs (9.5 Prepare has no options param)
- handleCheckin_test.go: call processPolicy with pp.Clone() (no nil collector arg)

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
ycombinator added a commit that referenced this pull request Sep 10, 2026
…iles

Conflict markers from the Mergify auto-backport of #7794 are resolved:
- ext.go: use nil Inputs (populated below) and bytes.Clone(OutputPermissions)
- handleCheckin.go: use pp.Policy.Data.Outputs (9.4 Prepare has no options param)
- handleCheckin_test.go: call processPolicy with pp.Clone() (no nil collector arg)

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
ycombinator added a commit that referenced this pull request Sep 10, 2026
Conflict markers from the Mergify auto-backport of #7794 resolved:

- ext.go: use bytes.Clone for json.RawMessage Agent/Fleet (8.19 types),
  take nil Inputs and bytes.Clone(OutputPermissions), add deepCloneMapAny/
  deepCloneSliceAny helpers; skip OTel fields absent from 8.19 PolicyData
- parsed_policy.go: remove Agent/Fleet field assignments absent from 8.19
  ParsedPolicy struct
- handleCheckin.go: use pp.Policy.Data.Outputs directly (pp is a clone);
  update SecretKeys on pp; skip prepareOTelExporters (not in 8.19);
  keep &keys pointer for *[]string SecretPaths type in 8.19 API
- handleCheckin_test.go: call processPolicy with pp.Clone() (keep HEAD
  goroutine structure; no nil collector arg)
- monitor.go: take cherry-pick push-back on rate-limit error and ctx cancel
- parsed_policy_test.go: add TestParsedPolicyCloneIsolation without Agent
  field references (absent from 8.19 ParsedPolicy); skip OTel/secrets tests

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
ycombinator added a commit that referenced this pull request Sep 10, 2026
…to prevent shared-state races (#7803)

* refactor: clone ParsedPolicy in processPolicy to prevent shared-state races (#7794)

* refactor: clone ParsedPolicy at dispatch site to prevent shared-state races

Move the ParsedPolicy clone from processPolicy to the monitor dispatch
loop, establishing ownership at the point where the policy transitions
from the shared monitor cache to an individual subscriber. Each channel
send now transfers an exclusively-owned *ParsedPolicy to its subscriber,
so processPolicy can treat its argument as its own.

Changes:
- monitor.go: compute cloned := policy.pp.Clone() before the select so
  it is not embedded in the send expression (Go evaluates all select
  case expressions on entry; doing clone inside the send ran it even
  when ctx.Done/default fired under the held mutex)
- handleCheckin.go: processPolicy no longer needs to clone pp
- handleCheckin_test.go: pass pp.Clone() per goroutine in the concurrent
  regression test, mirroring what the monitor now does at dispatch time
- ParsedPolicy.Clone() deep-copies every slice, map, and pointer field:
  SecretKeys and Policy.Namespaces via slices.Clone; Inputs, Roles
  (including RoleT.Raw bytes), and Outputs (including Output.Role
  pointers) element by element; Agent and Fleet via maps.Clone;
  Policy.Data via model.ClonePolicyData
- model.ClonePolicyData now also clones Agent/Fleet maps,
  OutputPermissions bytes, and output/OTel section maps via
  deepCloneMapAny — a new recursive helper that deep-clones
  map[string]any and []any trees; necessary because ProcessOutputSecret
  and prepareOTelExporters mutate nested map entries in-place;
  nil Inputs is preserved as nil
- TestParsedPolicyCloneIsolation verifies that mutating each field of a
  clone does not affect the original; SecretKeys check mutates an
  existing element (not append) to detect shared backing storage;
  Outputs check mutates both top-level and nested (ssl.key) paths to
  catch shallow-clone regressions

Relates #7739

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>

* fix: skip Clone() when ctx is already cancelled in dispatchPending

Guard policy.pp.Clone() with a ctx.Err() check so the potentially
expensive clone does not run while holding m.mut when the context was
already cancelled between m.limit.Wait and the clone site.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>

* refactor: narrow m.mut critical section in dispatchPending

Hold m.mut only for queue/map operations (popFront, pushFront,
policies lookup). Release it before m.limit.Wait, Clone(), and
channel sends so Subscribe/Unsubscribe/updatePolicy are not blocked
during those potentially slow operations.

policyT is a value type so the map lookup copies the struct, making
it safe to use policy.pp after the lock is released.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>

* refactor: use direct assignment for Agent/Fleet/Inputs in Clone()

maps.Clone on Agent and Fleet, and per-element maps.Clone on Inputs,
were shallow clones that implied deeper isolation than they provided.
processPolicy never accesses Agent or Fleet, and never mutates Inputs
elements — only overwrites the slice header via Policy.Data.Inputs.

Replace with direct assignment (Agent, Fleet) and slices.Clone (Inputs)
and document the invariant. Remove the now-incorrect Agent isolation
assertion from TestParsedPolicyCloneIsolation.

Also drops the unused "maps" import.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>

* fix: restore Agent/Fleet aliasing invariant in ParsedPolicy.Clone()

NewParsedPolicy assigns pp.Agent = p.Data.Agent and pp.Fleet =
p.Data.Fleet, so the two fields alias the same map. Clone() was
leaving clone.Agent/Fleet pointing at the original's maps while
clone.Policy.Data had freshly-cloned maps from ClonePolicyData,
breaking the invariant.

Set clone.Agent and clone.Fleet from the cloned Policy.Data after
ClonePolicyData runs, preserving the alias on the clone side.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>

* test: restore Agent isolation assertion after Clone() aliasing fix

After 0f62096 clone.Agent is derived from the cloned Policy.Data
(not a shared reference), so the stale comment is removed and the
top-level isolation assertion is restored.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>

---------

Co-authored-by: Claude Sonnet 4.6 <[email protected]>
(cherry picked from commit c907276)

# Conflicts:
#	internal/pkg/api/handleCheckin.go
#	internal/pkg/api/handleCheckin_test.go
#	internal/pkg/model/ext.go
#	internal/pkg/policy/monitor.go
#	internal/pkg/policy/parsed_policy_test.go

* fix: resolve backport conflict markers in 8.19 branch

Conflict markers from the Mergify auto-backport of #7794 resolved:

- ext.go: use bytes.Clone for json.RawMessage Agent/Fleet (8.19 types),
  take nil Inputs and bytes.Clone(OutputPermissions), add deepCloneMapAny/
  deepCloneSliceAny helpers; skip OTel fields absent from 8.19 PolicyData
- parsed_policy.go: remove Agent/Fleet field assignments absent from 8.19
  ParsedPolicy struct
- handleCheckin.go: use pp.Policy.Data.Outputs directly (pp is a clone);
  update SecretKeys on pp; skip prepareOTelExporters (not in 8.19);
  keep &keys pointer for *[]string SecretPaths type in 8.19 API
- handleCheckin_test.go: call processPolicy with pp.Clone() (keep HEAD
  goroutine structure; no nil collector arg)
- monitor.go: take cherry-pick push-back on rate-limit error and ctx cancel
- parsed_policy_test.go: add TestParsedPolicyCloneIsolation without Agent
  field references (absent from 8.19 ParsedPolicy); skip OTel/secrets tests

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>

* fix: remove unused cloneOTelSection and extra blank line in 8.19 ext.go

cloneOTelSection was introduced by the cherry-pick but is unused in 8.19
since PolicyData has no OTel fields. The extra blank line left by the
conflict-resolution commit caused a goimports formatting failure.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>

---------

Co-authored-by: Shaunak Kashyap <[email protected]>
Co-authored-by: Claude Sonnet 4.6 <[email protected]>
ycombinator added a commit that referenced this pull request Sep 10, 2026
…o prevent shared-state races (#7801)

* refactor: clone ParsedPolicy in processPolicy to prevent shared-state races (#7794)

* refactor: clone ParsedPolicy at dispatch site to prevent shared-state races

Move the ParsedPolicy clone from processPolicy to the monitor dispatch
loop, establishing ownership at the point where the policy transitions
from the shared monitor cache to an individual subscriber. Each channel
send now transfers an exclusively-owned *ParsedPolicy to its subscriber,
so processPolicy can treat its argument as its own.

Changes:
- monitor.go: compute cloned := policy.pp.Clone() before the select so
  it is not embedded in the send expression (Go evaluates all select
  case expressions on entry; doing clone inside the send ran it even
  when ctx.Done/default fired under the held mutex)
- handleCheckin.go: processPolicy no longer needs to clone pp
- handleCheckin_test.go: pass pp.Clone() per goroutine in the concurrent
  regression test, mirroring what the monitor now does at dispatch time
- ParsedPolicy.Clone() deep-copies every slice, map, and pointer field:
  SecretKeys and Policy.Namespaces via slices.Clone; Inputs, Roles
  (including RoleT.Raw bytes), and Outputs (including Output.Role
  pointers) element by element; Agent and Fleet via maps.Clone;
  Policy.Data via model.ClonePolicyData
- model.ClonePolicyData now also clones Agent/Fleet maps,
  OutputPermissions bytes, and output/OTel section maps via
  deepCloneMapAny — a new recursive helper that deep-clones
  map[string]any and []any trees; necessary because ProcessOutputSecret
  and prepareOTelExporters mutate nested map entries in-place;
  nil Inputs is preserved as nil
- TestParsedPolicyCloneIsolation verifies that mutating each field of a
  clone does not affect the original; SecretKeys check mutates an
  existing element (not append) to detect shared backing storage;
  Outputs check mutates both top-level and nested (ssl.key) paths to
  catch shallow-clone regressions

Relates #7739

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>

* fix: skip Clone() when ctx is already cancelled in dispatchPending

Guard policy.pp.Clone() with a ctx.Err() check so the potentially
expensive clone does not run while holding m.mut when the context was
already cancelled between m.limit.Wait and the clone site.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>

* refactor: narrow m.mut critical section in dispatchPending

Hold m.mut only for queue/map operations (popFront, pushFront,
policies lookup). Release it before m.limit.Wait, Clone(), and
channel sends so Subscribe/Unsubscribe/updatePolicy are not blocked
during those potentially slow operations.

policyT is a value type so the map lookup copies the struct, making
it safe to use policy.pp after the lock is released.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>

* refactor: use direct assignment for Agent/Fleet/Inputs in Clone()

maps.Clone on Agent and Fleet, and per-element maps.Clone on Inputs,
were shallow clones that implied deeper isolation than they provided.
processPolicy never accesses Agent or Fleet, and never mutates Inputs
elements — only overwrites the slice header via Policy.Data.Inputs.

Replace with direct assignment (Agent, Fleet) and slices.Clone (Inputs)
and document the invariant. Remove the now-incorrect Agent isolation
assertion from TestParsedPolicyCloneIsolation.

Also drops the unused "maps" import.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>

* fix: restore Agent/Fleet aliasing invariant in ParsedPolicy.Clone()

NewParsedPolicy assigns pp.Agent = p.Data.Agent and pp.Fleet =
p.Data.Fleet, so the two fields alias the same map. Clone() was
leaving clone.Agent/Fleet pointing at the original's maps while
clone.Policy.Data had freshly-cloned maps from ClonePolicyData,
breaking the invariant.

Set clone.Agent and clone.Fleet from the cloned Policy.Data after
ClonePolicyData runs, preserving the alias on the clone side.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>

* test: restore Agent isolation assertion after Clone() aliasing fix

After 0f62096 clone.Agent is derived from the cloned Policy.Data
(not a shared reference), so the stale comment is removed and the
top-level isolation assertion is restored.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>

---------

Co-authored-by: Claude Sonnet 4.6 <[email protected]>
(cherry picked from commit c907276)

# Conflicts:
#	internal/pkg/api/handleCheckin.go
#	internal/pkg/api/handleCheckin_test.go

* fix: resolve backport conflict markers in 9.5 handleCheckin files

Conflict markers from the Mergify auto-backport of #7794 are resolved:
- handleCheckin.go: use pp.Policy.Data.Outputs (9.5 Prepare has no options param)
- handleCheckin_test.go: call processPolicy with pp.Clone() (no nil collector arg)

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>

---------

Co-authored-by: Shaunak Kashyap <[email protected]>
Co-authored-by: Claude Sonnet 4.6 <[email protected]>
ycombinator added a commit that referenced this pull request Sep 10, 2026
…o prevent shared-state races (#7802)

* refactor: clone ParsedPolicy in processPolicy to prevent shared-state races (#7794)

* refactor: clone ParsedPolicy at dispatch site to prevent shared-state races

Move the ParsedPolicy clone from processPolicy to the monitor dispatch
loop, establishing ownership at the point where the policy transitions
from the shared monitor cache to an individual subscriber. Each channel
send now transfers an exclusively-owned *ParsedPolicy to its subscriber,
so processPolicy can treat its argument as its own.

Changes:
- monitor.go: compute cloned := policy.pp.Clone() before the select so
  it is not embedded in the send expression (Go evaluates all select
  case expressions on entry; doing clone inside the send ran it even
  when ctx.Done/default fired under the held mutex)
- handleCheckin.go: processPolicy no longer needs to clone pp
- handleCheckin_test.go: pass pp.Clone() per goroutine in the concurrent
  regression test, mirroring what the monitor now does at dispatch time
- ParsedPolicy.Clone() deep-copies every slice, map, and pointer field:
  SecretKeys and Policy.Namespaces via slices.Clone; Inputs, Roles
  (including RoleT.Raw bytes), and Outputs (including Output.Role
  pointers) element by element; Agent and Fleet via maps.Clone;
  Policy.Data via model.ClonePolicyData
- model.ClonePolicyData now also clones Agent/Fleet maps,
  OutputPermissions bytes, and output/OTel section maps via
  deepCloneMapAny — a new recursive helper that deep-clones
  map[string]any and []any trees; necessary because ProcessOutputSecret
  and prepareOTelExporters mutate nested map entries in-place;
  nil Inputs is preserved as nil
- TestParsedPolicyCloneIsolation verifies that mutating each field of a
  clone does not affect the original; SecretKeys check mutates an
  existing element (not append) to detect shared backing storage;
  Outputs check mutates both top-level and nested (ssl.key) paths to
  catch shallow-clone regressions

Relates #7739

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>

* fix: skip Clone() when ctx is already cancelled in dispatchPending

Guard policy.pp.Clone() with a ctx.Err() check so the potentially
expensive clone does not run while holding m.mut when the context was
already cancelled between m.limit.Wait and the clone site.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>

* refactor: narrow m.mut critical section in dispatchPending

Hold m.mut only for queue/map operations (popFront, pushFront,
policies lookup). Release it before m.limit.Wait, Clone(), and
channel sends so Subscribe/Unsubscribe/updatePolicy are not blocked
during those potentially slow operations.

policyT is a value type so the map lookup copies the struct, making
it safe to use policy.pp after the lock is released.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>

* refactor: use direct assignment for Agent/Fleet/Inputs in Clone()

maps.Clone on Agent and Fleet, and per-element maps.Clone on Inputs,
were shallow clones that implied deeper isolation than they provided.
processPolicy never accesses Agent or Fleet, and never mutates Inputs
elements — only overwrites the slice header via Policy.Data.Inputs.

Replace with direct assignment (Agent, Fleet) and slices.Clone (Inputs)
and document the invariant. Remove the now-incorrect Agent isolation
assertion from TestParsedPolicyCloneIsolation.

Also drops the unused "maps" import.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>

* fix: restore Agent/Fleet aliasing invariant in ParsedPolicy.Clone()

NewParsedPolicy assigns pp.Agent = p.Data.Agent and pp.Fleet =
p.Data.Fleet, so the two fields alias the same map. Clone() was
leaving clone.Agent/Fleet pointing at the original's maps while
clone.Policy.Data had freshly-cloned maps from ClonePolicyData,
breaking the invariant.

Set clone.Agent and clone.Fleet from the cloned Policy.Data after
ClonePolicyData runs, preserving the alias on the clone side.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>

* test: restore Agent isolation assertion after Clone() aliasing fix

After 0f62096 clone.Agent is derived from the cloned Policy.Data
(not a shared reference), so the stale comment is removed and the
top-level isolation assertion is restored.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>

---------

Co-authored-by: Claude Sonnet 4.6 <[email protected]>
(cherry picked from commit c907276)

# Conflicts:
#	internal/pkg/api/handleCheckin.go
#	internal/pkg/api/handleCheckin_test.go
#	internal/pkg/model/ext.go

* fix: resolve backport conflict markers in 9.4 handleCheckin and ext files

Conflict markers from the Mergify auto-backport of #7794 are resolved:
- ext.go: use nil Inputs (populated below) and bytes.Clone(OutputPermissions)
- handleCheckin.go: use pp.Policy.Data.Outputs (9.4 Prepare has no options param)
- handleCheckin_test.go: call processPolicy with pp.Clone() (no nil collector arg)

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>

---------

Co-authored-by: Shaunak Kashyap <[email protected]>
Co-authored-by: Claude Sonnet 4.6 <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

backport-active-all Automated backport with mergify to all the active branches bug Something isn't working Team:Elastic-Agent-Control-Plane Label for the Agent Control Plane team

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants