Skip to content

pkg/dyninst: Fix procscan backoff - #54400

Merged
gh-worker-dd-mergequeue-cf854d[bot] merged 16 commits into
mainfrom
grantseltzer/fix-procscan-backoff
Aug 10, 2026
Merged

pkg/dyninst: Fix procscan backoff#54400
gh-worker-dd-mergequeue-cf854d[bot] merged 16 commits into
mainfrom
grantseltzer/fix-procscan-backoff

Conversation

@grantseltzer

@grantseltzer grantseltzer commented Aug 4, 2026

Copy link
Copy Markdown
Member

What does this PR do?

Changes how the live debugger finds processes to instrument. It used to look at each process exactly three times, when the process turned 3, 100 and 1000 seconds old. If the tracer inside that process had not announced itself by the third look, that process was never instrumented again for as long as it ran. Now every process is checked repeatedly, with a growing gap between checks, until it is either instrumented or gone. A slow start is a delay of seconds instead of a permanent miss.

Two supporting changes come with it. Processes that are not Go programs are identified with a cheap check and set aside, so that looking at everything repeatedly stays affordable. And scans now run on a plain five second timer, where the old one stretched itself in proportion to how long the previous scan took, with no upper limit.

Includes benchmarks for the cost of a scan.

Motivation

This came out of an investigation into a service that took roughly half an hour to upload its debugging symbols. Symbols are only uploaded once a service is being instrumented, so a discovery failure surfaces as an unexplained delay somewhere else entirely, which is what made it hard to track down.

The tracer announces itself at the very end of its own startup, behind a network call that can hang for ten seconds. The first look happened a few seconds after the process started, so the tracer could not win that race, and the design gave it only two more chances ever. The same investigation turned up a second problem: because the timer grew with scan duration and had no ceiling, a single slow scan could push the next one out far enough to swallow two of the three chances a process ever got.

Describe how you validated your changes

Extended the existing table-driven scanner tests to cover the retry schedule, a tracer that only announces itself after many attempts, agent restart, process ID reuse, permission failures, processes that are not Go programs, and the fixed timer.

Added benchmarks, since the new loop looks at every process every time. On a host with two thousand processes, an ordinary scan costs about 0.44% of one core, and the first scan after a restart costs about 2% of a core for that one scan.

Additional Notes

A process that starts as a shell script and later replaces itself with the real service keeps the same process ID, so if we happen to look at it while it is still the script, we write it off along with the service it becomes if it's not Go. Container enrypoints happen on proc start so this is not an issue except the case of a proc running as a script that does set up and does an exec. I don't think that really matters for support.

@grantseltzer
grantseltzer requested review from a team as code owners August 4, 2026 01:35
@grantseltzer grantseltzer added changelog/no-changelog No changelog entry needed qa/done QA done before merge and regressions are covered by tests team/debugger labels Aug 4, 2026
@dd-octo-sts dd-octo-sts Bot added internal Identify a non-fork PR team/agent-build labels Aug 4, 2026
@github-actions github-actions Bot added the long review PR is complex, plan time to review it label Aug 4, 2026

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: b05f0e86a5

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread pkg/dyninst/procsubscribe/procscan/scanner.go
@dd-octo-sts

dd-octo-sts Bot commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Files inventory check summary

File checks results against ancestor 8db82fe9:

Results for datadog-agent_7.84.0~devel.git.57.c64775e.pipeline.130016266-1_amd64.deb:

No change detected

Results for datadog-iot-agent_7.84.0~devel.git.57.c64775e.pipeline.130016266-1_amd64.deb:

No change detected

@dd-octo-sts

dd-octo-sts Bot commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Static quality checks

✅ Please find below the results from static quality gates
Comparison made with ancestor 8db82fe
📊 Static Quality Gates Dashboard
🔗 SQG Job

Successful checks

Info

Quality gate Change Size (prev → curr → max)
agent_deb_amd64 -24.06 KiB (0.00% reduction, +0.48% of buffer) 759.546 → 759.523 → 764.430
agent_deb_amd64_fips -28.06 KiB (0.00% reduction, +0.98% of buffer) 712.888 → 712.861 → 715.680
agent_rpm_amd64 -24.06 KiB (0.00% reduction, +0.48% of buffer) 759.530 → 759.506 → 764.400
agent_rpm_amd64_fips -28.06 KiB (0.00% reduction, +0.98% of buffer) 712.872 → 712.844 → 715.680
agent_rpm_arm64 -24.06 KiB (0.00% reduction, +247.86% of buffer) 735.491 → 735.467 → 735.500
agent_rpm_arm64_fips -24.06 KiB (0.00% reduction, +1.53% of buffer) 692.104 → 692.080 → 693.640
agent_suse_amd64 -24.06 KiB (0.00% reduction, +0.48% of buffer) 759.530 → 759.506 → 764.400
agent_suse_amd64_fips -28.06 KiB (0.00% reduction, +0.98% of buffer) 712.872 → 712.844 → 715.680
agent_suse_arm64 -24.06 KiB (0.00% reduction, +247.86% of buffer) 735.491 → 735.467 → 735.500
agent_suse_arm64_fips -24.06 KiB (0.00% reduction, +1.53% of buffer) 692.104 → 692.080 → 693.640
docker_agent_amd64 -24.06 KiB (0.00% reduction, +1.30% of buffer) 818.158 → 818.134 → 819.970
docker_agent_arm64 -24.06 KiB (0.00% reduction, +1.54% of buffer) 819.162 → 819.138 → 820.690
docker_agent_jmx_amd64 -24.06 KiB (0.00% reduction, +1.40% of buffer) 1009.055 → 1009.032 → 1010.730
docker_agent_jmx_arm64 -24.06 KiB (0.00% reduction, +1.42% of buffer) 998.712 → 998.688 → 1000.370
19 successful checks with minimal change (< 2 KiB)
Quality gate Current Size
agent_heroku_amd64 312.273 MiB
agent_msi 643.247 MiB
docker_cluster_agent_amd64 210.227 MiB
docker_cluster_agent_arm64 223.267 MiB
docker_cws_instrumentation_amd64 7.439 MiB
docker_cws_instrumentation_arm64 6.877 MiB
docker_dogstatsd_amd64 39.479 MiB
docker_dogstatsd_arm64 37.560 MiB
docker_host_profiler_amd64 305.801 MiB
docker_host_profiler_arm64 317.116 MiB
dogstatsd_deb_amd64 30.220 MiB
dogstatsd_deb_arm64 28.243 MiB
dogstatsd_rpm_amd64 30.220 MiB
dogstatsd_suse_amd64 30.220 MiB
iot_agent_deb_amd64 46.416 MiB
iot_agent_deb_arm64 43.091 MiB
iot_agent_deb_armhf 43.871 MiB
iot_agent_rpm_amd64 46.416 MiB
iot_agent_suse_amd64 46.415 MiB

@cit-pr-commenter-54b7da

cit-pr-commenter-54b7da Bot commented Aug 4, 2026

Copy link
Copy Markdown

Regression Detector

Regression Detector Results

Metrics dashboard
Target profiles
Run ID: 59384ac3-2f61-4111-81ff-962d6a887fae

Baseline: 78d82eb
Comparison: 623b04c
Diff

Optimization Goals: ✅ No significant changes detected

Fine details of change detection per experiment

perf experiment goal Δ mean % Δ mean % CI trials links
quality_gate_logs % cpu utilization +1.66 [+0.79, +2.53] 1 Logs bounds checks dashboard
quality_gate_idle_all_features memory utilization +0.22 [+0.18, +0.26] 1 Logs bounds checks dashboard
quality_gate_idle memory utilization +0.21 [+0.17, +0.26] 1 Logs bounds checks dashboard
quality_gate_security_no_fs_load memory utilization -0.05 [-0.15, +0.04] 1 Logs bounds checks dashboard
quality_gate_metrics_logs memory utilization -0.06 [-0.30, +0.19] 1 Logs bounds checks dashboard
quality_gate_security_mean_fs_load memory utilization -0.11 [-0.15, -0.08] 1 Logs bounds checks dashboard
quality_gate_private_action_runner memory utilization -0.59 [-0.71, -0.47] 1 Logs bounds checks dashboard
quality_gate_security_idle memory utilization -0.70 [-0.76, -0.64] 1 Logs bounds checks dashboard

Bounds Checks: ✅ Passed

perf experiment bounds_check_name replicates_passed observed_value links
quality_gate_idle intake_connections 10/10 3 ≤ 4 bounds checks dashboard
quality_gate_idle memory_usage 10/10 147.51MiB ≤ 154MiB bounds checks dashboard
quality_gate_idle total_bytes_received 10/10 730.74KiB ≤ 819.20KiB bounds checks dashboard
quality_gate_idle_all_features intake_connections 10/10 3 ≤ 4 bounds checks dashboard
quality_gate_idle_all_features memory_usage 10/10 491.79MiB ≤ 512MiB bounds checks dashboard
quality_gate_idle_all_features total_bytes_received 10/10 1.13MiB ≤ 1.25MiB bounds checks dashboard
quality_gate_logs intake_connections 10/10 17 ≤ 40 bounds checks dashboard
quality_gate_logs memory_usage 10/10 185.15MiB ≤ 195MiB bounds checks dashboard
quality_gate_logs missed_bytes 10/10 0B = 0B bounds checks dashboard
quality_gate_logs total_bytes_received 10/10 264.17MiB ≤ 292MiB bounds checks dashboard
quality_gate_metrics_logs cpu_usage 10/10 359.98 ≤ 2000 bounds checks dashboard
quality_gate_metrics_logs intake_connections 10/10 19 ≤ 40 bounds checks dashboard
quality_gate_metrics_logs memory_usage 10/10 412.64MiB ≤ 430MiB bounds checks dashboard
quality_gate_metrics_logs missed_bytes 10/10 0B = 0B bounds checks dashboard
quality_gate_metrics_logs total_bytes_received 10/10 0.94GiB ≤ 1.04GiB bounds checks dashboard
quality_gate_private_action_runner memory_usage 10/10 71.46MiB ≤ 75MiB bounds checks dashboard
quality_gate_security_idle cpu_usage 10/10 29.43 ≤ 100 bounds checks dashboard
quality_gate_security_idle memory_usage 10/10 300.90MiB ≤ 330MiB bounds checks dashboard
quality_gate_security_mean_fs_load cpu_usage 10/10 60.40 ≤ 200 bounds checks dashboard
quality_gate_security_mean_fs_load memory_usage 10/10 275.05MiB ≤ 310MiB bounds checks dashboard
quality_gate_security_no_fs_load cpu_usage 10/10 20.82 ≤ 100 bounds checks dashboard
quality_gate_security_no_fs_load memory_usage 10/10 285.79MiB ≤ 320MiB bounds checks dashboard

Explanation

Confidence level: 90.00%
Effect size tolerance: |Δ mean %| ≥ 5.00%

Performance changes are noted in the perf column of each table:

  • ✅ = significantly better comparison variant performance
  • ❌ = significantly worse comparison variant performance
  • ➖ = no significant change in performance

A regression test is an A/B test of target performance in a repeatable rig, where "performance" is measured as "comparison variant minus baseline variant" for an optimization goal (e.g., ingress throughput). Due to intrinsic variability in measuring that goal, we can only estimate its mean value for each experiment; we report uncertainty in that value as a 90.00% confidence interval denoted "Δ mean % CI".

For each experiment, we decide whether a change in performance is a "regression" -- a change worth investigating further -- if all of the following criteria are true:

  1. Its estimated |Δ mean %| ≥ 5.00%, indicating the change is big enough to merit a closer look.

  2. Its 90.00% confidence interval "Δ mean % CI" does not contain zero, indicating that if our statistical model is accurate, there is at least a 90.00% chance there is a difference in performance between baseline and comparison variants.

  3. Its configuration does not mark it "erratic".

Replicate Execution Details

We run multiple replicates for each experiment/variant. However, we allow replicates to be automatically retried if there are any failures, up to 8 times, at which point the replicate is marked dead and we are unable to run analysis for the entire experiment. We call each of these attempts at running replicates a replicate execution. This section lists all replicate executions that failed due to the target crashing or being oom killed.

Note: In the below tables we bucket failures by experiment, variant, and failure type. For each of these buckets we list out the replicate indexes that failed with an annotation signifying how many times said replicate failed with the given failure mode. In the below example the baseline variant of the experiment named experiment_with_failures had two replicates that failed by oom kills. Replicate 0, which failed 8 executions, and replicate 1 which failed 6 executions, all with the same failure mode.

Experiment Variant Replicates Failure Logs Debug Dashboard
experiment_with_failures baseline 0 (x8) 1 (x6) Oom killed Debug Dashboard

The debug dashboard links will take you to a debugging dashboard specifically designed to investigate replicate execution failures.

❌ Retried Profiling Replicate Execution Failures (ddprof)

Note: Profiling replicas may still be executing. See the debug dashboard for up to date status.

Experiment Variant Replicates Failure Debug Dashboard
quality_gate_idle_all_features baseline 10 Oom killed Debug Dashboard
quality_gate_idle_all_features comparison 10 Oom killed Debug Dashboard
quality_gate_logs baseline 10 Oom killed Debug Dashboard
quality_gate_logs comparison 10 Oom killed Debug Dashboard
quality_gate_metrics_logs baseline 10 Oom killed Debug Dashboard
quality_gate_metrics_logs comparison 10 Oom killed Debug Dashboard
quality_gate_security_idle baseline 10 Crashed (exit code: 134) Debug Dashboard
quality_gate_security_idle comparison 10 Crashed (exit code: 134) Debug Dashboard
quality_gate_security_mean_fs_load comparison 10 Crashed (exit code: 134) Debug Dashboard
quality_gate_security_no_fs_load baseline 10 Crashed (exit code: 134) Debug Dashboard

CI Pass/Fail Decision

Passed. All Quality Gates passed.

  • quality_gate_logs, bounds check intake_connections: 10/10 replicas passed. Gate passed.
  • quality_gate_logs, bounds check memory_usage: 10/10 replicas passed. Gate passed.
  • quality_gate_logs, bounds check total_bytes_received: 10/10 replicas passed. Gate passed.
  • quality_gate_logs, bounds check missed_bytes: 10/10 replicas passed. Gate passed.
  • quality_gate_idle_all_features, bounds check intake_connections: 10/10 replicas passed. Gate passed.
  • quality_gate_idle_all_features, bounds check total_bytes_received: 10/10 replicas passed. Gate passed.
  • quality_gate_idle_all_features, bounds check memory_usage: 10/10 replicas passed. Gate passed.
  • quality_gate_security_mean_fs_load, bounds check memory_usage: 10/10 replicas passed. Gate passed.
  • quality_gate_security_mean_fs_load, bounds check cpu_usage: 10/10 replicas passed. Gate passed.
  • quality_gate_security_no_fs_load, bounds check memory_usage: 10/10 replicas passed. Gate passed.
  • quality_gate_security_no_fs_load, bounds check cpu_usage: 10/10 replicas passed. Gate passed.
  • quality_gate_idle, bounds check total_bytes_received: 10/10 replicas passed. Gate passed.
  • quality_gate_idle, bounds check memory_usage: 10/10 replicas passed. Gate passed.
  • quality_gate_idle, bounds check intake_connections: 10/10 replicas passed. Gate passed.
  • quality_gate_private_action_runner, bounds check memory_usage: 10/10 replicas passed. Gate passed.
  • quality_gate_metrics_logs, bounds check memory_usage: 10/10 replicas passed. Gate passed.
  • quality_gate_metrics_logs, bounds check cpu_usage: 10/10 replicas passed. Gate passed.
  • quality_gate_metrics_logs, bounds check intake_connections: 10/10 replicas passed. Gate passed.
  • quality_gate_metrics_logs, bounds check missed_bytes: 10/10 replicas passed. Gate passed.
  • quality_gate_metrics_logs, bounds check total_bytes_received: 10/10 replicas passed. Gate passed.
  • quality_gate_security_idle, bounds check memory_usage: 10/10 replicas passed. Gate passed.
  • quality_gate_security_idle, bounds check cpu_usage: 10/10 replicas passed. Gate passed.

@grantseltzer
grantseltzer force-pushed the grantseltzer/fix-procscan-backoff branch 2 times, most recently from 5f13038 to d18f2c0 Compare August 5, 2026 19:47
@datadog-official

datadog-official Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

View session in Datadog

Bits Code status: ✅ Done

Comment @DataDog to request changes

Comment thread pkg/dyninst/procsubscribe/procscan/executable_filter.go Outdated
Comment thread pkg/dyninst/procsubscribe/procscan/executable_filter.go Outdated
Comment thread pkg/dyninst/procsubscribe/procscan/scanner.go Outdated
Comment thread pkg/dyninst/procsubscribe/procscan/scanner.go Outdated
Comment thread pkg/dyninst/procsubscribe/procscan/scanner.go Outdated
Comment thread pkg/dyninst/procsubscribe/procscan/scanner.go Outdated
Comment thread pkg/dyninst/procsubscribe/procscan/scanner.go
Comment thread pkg/dyninst/procsubscribe/procscan/scanner.go Outdated
Comment thread pkg/dyninst/procsubscribe/procscan/scanner.go Outdated
Comment thread pkg/dyninst/procsubscribe/procscan/scanner.go Outdated
@grantseltzer

grantseltzer commented Aug 6, 2026

Copy link
Copy Markdown
Member Author

Now that i've refined this cleanly, here's a good simple explanation that i've verified:

Every five seconds the agent walks the list of running processes. For each one it asks a short series of questions, cheapest first: have I already instrumented this, is it even due for another look yet, what program is it running, was that program built with Go, and has its tracer published the details we need. The first question that comes back "no" or "not yet" ends the examination of that process for this round. Most processes stop at the second question for the cost of a single lookup, which is what keeps looking at everything every five seconds affordable.

The important part is what happens after a "no". Nothing is ever a final answer. The process goes back in the queue and gets looked at again later, with the wait doubling each time. The first look lands within five seconds of the process starting, then roughly five seconds later, then fifteen, thirty five, seventy five, and so on up to a ceiling of five minutes, repeating at that ceiling for as long as the process lives. So a service whose tracer takes ten seconds to announce itself is picked up on the second or third look instead of being missed forever, and a startup script that spends a minute preparing before launching the real service is picked up shortly after that launch. The only thing that takes a process out of consideration is the process exiting.

Two supporting details make it hold together. A process is identified by its id together with the moment it started, not by its id alone, so when the system recycles an id we never mistake the new process for the dead one. And any process a scan does not see is forgotten on the spot, so the bookkeeping can never grow larger than the number of things actually running.

Comment thread pkg/dyninst/procsubscribe/procscan/executable_filter.go Outdated
Comment thread pkg/dyninst/procsubscribe/procscan/scanner.go Outdated
Comment on lines +59 to +61
// candidate is the retry state for a process that has not been instrumented
// yet, whatever the reason.
type candidate struct {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Are we going to have 1 of these structs in memory, potentially forever? candidate naming convention seems off compared to the description

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Nope, the first scan that doesn't see the process deletes it, so nothing outlives the process it describes.

One per running process that isn't instrumented yet, so it's bounded by live processes, which the kernel bounds by pid_max. Each entry is about 32 bytes and the first scan that doesn't see the process deletes it, so nothing outlives the process it describes.


// WithRetryBackoff sets the delay before the first retry of a tracer metadata
// read and the cap that the doubling delay grows to.
func WithRetryBackoff(base, maxDelay time.Duration) Option {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

There aren't pre-existing utilities for backoff?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Claude originally pulled in a util but we needed to change it and it became more trouble than it was worth. It was pkg/util/backoff. It randomizes the delay inside a range as opposed to a simple delay, Also it works in float64 seconds while everything here is based on ticks.

It was implementing all of that and I was like for fucks sake it's 5 lines of code.


// mustNewLRU panics if the cache creation fails, which only happens if the size
// is non-positive.
func mustNewLRU[K comparable, V any](size int) *lru.Cache[K, V] {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I wish more library developers would ship with a Must

Comment thread pkg/dyninst/procsubscribe/procscan/scanner.go
// The start time is read on every scan rather than cached by pid: it is
// the only thing that tells us whether this is still the same process,
// so a cached copy would be exactly as stale as the answer we need.
startTime, err := p.readStartTime(int32(pid))

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Any performance concerns doing this every time?

@grantseltzer grantseltzer Aug 7, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

It is the main cost of a scan but it's deliberate. The start time is the only thing that tells us a pid still refers to the same proc so we cannot cache it.

That said it's a small impact. It's a open+read+close syscall for each process every 5s. The read isn't hitting disk, procfs is completely in memory. I ran the benchmarks on my machine and it was 21ms of reads per scan. I expect there's a small fixed cost and it scales relatively linearly with number of procs from there.

p.scheduleRetry(key, c, now)
continue
}
if tracerMetadata.TracerLanguage != "go" {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Could TracerLanguage ever be an empty string?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Yea I don't think it's validated anywhere. But even if the payload is missing the language completely it'll fall under != go and have the behavior that we want. It'll get retried (as opposed to written off forever how it used to).

Comment thread pkg/dyninst/procsubscribe/remote_config.go Outdated
@grantseltzer
grantseltzer force-pushed the grantseltzer/fix-procscan-backoff branch from 12bee1c to 21a7a58 Compare August 7, 2026 17:39
@grantseltzer
grantseltzer requested a review from a team as a code owner August 7, 2026 17:39
Removes the per verdict counters the scanner kept and reported through the
system-probe stats endpoint. Nothing consumed them beyond that endpoint, no
telemetry metric or monitor was built on them, and ten counters is more than a
debug only surface earns. The distinction between a tracer that has not
published its metadata yet and one whose metadata cannot be read now survives
only as a log level, since the scanner retries both on the same schedule, and
the snapshot timelines assert the retry schedule directly through the
candidate state instead. The executable cache dedup, which was the one thing
the counters were carrying that nothing else checked, moves to
TestSyntheticProcfsScanWork, which now pins the number of binaries a first
scan parses against real files rather than merely asserting it is non zero;
that assertion is stronger than the counter's, and was verified to fail when
the cache is disabled. The scan cadence stats are kept, since they report the
interval this change caps.
Replaces the adaptive scan cadence with a plain five second tick. The interval
used to be the scan interval plus a hundred times however long the last scan
took, capped at thirty seconds, which held scanning to one percent of a core
but meant the interval was never actually the configured value: on a host with
two thousand processes it settled around five seconds, and the first scan after
a restart pushed the next one out past thirteen. That is a lot of machinery and
a lot of explaining for an optimization nothing has asked for yet, so it goes;
if scan cost turns out to matter it can come back with evidence behind it. The
max scan interval goes with it, since that ceiling existed only to bound the
penalty term, as does the reported scan interval, since a constant is not worth
reporting. The jitter goes too: its factor was never set outside tests, so it
had been multiplying every interval by exactly one, and the scans are local
work that nothing downstream can be stampeded by. The interval moves from three
seconds to five because every process on the host costs a stat read on every
scan, which the calibration benchmark puts at twelve microseconds against real
procfs, or about one percent of a core at two thousand processes every three
seconds; five buys most of that back and still leaves three retries inside a
process' first minute. The retry backoff base moves with it, since it exists to
put the first retry on the next scan and a shorter value would buy nothing. The
test that pinned the ceiling now pins the interval instead, and was verified to
fail if the penalty is reintroduced.
Swaps the candidate set from an LRU cache to a map. The set needs no bound of
its own: an entry appears when a process is evaluated and is dropped by the
first scan that does not see the process, so it cannot outgrow the number of
live processes, which the kernel already bounds by pid_max. The cap was that
same bound written down a second time, and the eviction path it guarded is
unreachable in practice, so it took a constant, an option, a config field and
a snapshot timeline with it. Sweeping exited candidates now deletes while
ranging rather than allocating a slice of every key on every scan, worth about
sixteen kilobytes a scan on a host with two thousand candidates. This also
removes an empty if statement left behind when the scanner counters were
deleted, which compiled and passed vet because the eviction result was still
read in the condition.
A process found not to be running a Go binary is now never looked at again,
rather than re-examined on a one minute ceiling. Its candidate entry records
that verdict and every later scan skips it for the cost of the stat read that
enumerating pids costs anyway; the entry goes away when the process exits, as
any other does. This reverses the exec accommodation: a shell entrypoint that
later execs the real service keeps its pid and start time, so that service is
now missed for as long as it runs. That trade is deliberate, and it takes with
it the second backoff ceiling, its option, its config field, the executable
key each candidate carried, the exec detection that reset a schedule, and the
timeline covering it. The saving is simplicity rather than cost: the one
minute ceiling already recovered nearly all of what re-examining these
processes was spending, so steady state at two thousand processes is unmoved
at around 0.44% of a core.
Deletes the scanner's own copy of the tracer memfd search and calls
tracermetadata.GetTracerMetadata again, as the code did before this branch.
The copy existed to tell a process that has published no metadata apart from
one whose descriptors we are not allowed to read, because the shared helper
reports both as not found, and that distinction fed two counters that have
since been removed. All it bought after that was a warning rather than a trace
line for the permission case, which the same scan already declines to warn
about when resolving an executable fails the same way. The metadata read now
goes through the same rate limited logging as every other step, with a not
found result added to the errors that step considers uninteresting, since most
processes on a host have published nothing. Both paths were the same search
already, fd three first and then the rest of the directory, and both cap the
read at the same sixty four kilobytes.
Trims the comments that were restating what the code says, renames the ELF
section set to goELFSections, and shortens the note on the candidate map. A Go
binary whose tracer reports some other language is now written off rather than
retried forever: nothing about that answer is going to change, so it belongs
with the binaries that are not Go at all. The write-off itself moves into a
small helper now that two branches reach it, which is also where the note about
exec keeping a pid and start time now lives.
Removes the one second floor a process had to clear before the scanner would
look at it. It was load bearing when a process got a single look at a fixed
age and a wrong guess meant a permanent miss; with the loop retrying, looking
early and being wrong costs nothing. What it was costing is discovery latency:
with a five second tick, only processes born in the last second before a scan
were affected, and those had their first look pushed out a whole interval, so
roughly a fifth of all processes waited an extra five seconds to be found.

Nothing downstream needs the floor. Discovery on its own does no actuator work,
since a process with no probes is ignored by the state machine, and probes only
arrive after Remote Config answers for that runtime id, which is a network
round trip that no short lived process survives. If a process does vanish
mid attach, the attach error is caught, the program is unloaded and the entry
is reaped when the removal arrives. The floor did also cover the window between
fork and exec, where a child still shows its parent's executable and would now
be written off permanently, but that window is microseconds and the process
would have to be a traced Go service to matter.
A process found not to be running a Go binary, or whose tracer reports a
language other than Go, was recorded as never instrumentable and skipped by
every later scan. exec keeps a process' pid and start time, so a wrapper
script that was still running its entrypoint the first time we looked at it
took the service it later became down with it, for as long as that service
ran. That is the same permanent miss this branch set out to remove, and the
old windowed scanner did not have it, since it looked again at 100s and 1000s
and never inspected the executable at all. Both verdicts now go on the same
backoff as every other one, so exiting is once again the only terminal state,
and the write-off, its never sentinel and the snapshot field that reported it
are gone. The cap is now read from the scanner rather than passed down, since
there is one of them again. Measured on the same host as the other numbers on
this branch, resolving an executable against real procfs costs 14.8us, so
re-resolving every one of two thousand processes once per five minute cap is
0.01% of a core amortized, against the 0.44% a scan already costs to read
their start times; the steady state benchmark is unmoved at 0.45%. Validated
with a new timeline that walks a wrapper through three looks, execs it into a
Go binary at the same pid and start time, and shows the service discovered on
the next look due.
Takes the reviewer's wording for the executable filter's doc comment and
drops the doc comments from the retry constants. Rewords the scan interval
constant, which called itself the delay between two scans when it is the
delay between the end of one and the start of the next, so a slow scan
pushes the following one out by its own duration. Also cuts a sentence from
the seenAt field that restated what the sweep reading it already documents.
…uration

The scan loop rested a fixed five seconds after every scan, so the fraction of a
core it spent was the scan's own duration over five seconds and nothing bounded
it. A scan reads the start time of every process on the host and searches the
open descriptors of those due for a retry, and neither the number of processes
nor the number of descriptors any one of them holds is under the scanner's
control, so that fraction is not something the code can predict: a half second
scan is nine percent of a core, and a five second one is half of it. The rest is
now the larger of the interval and twenty times how long the scan took, which
holds the loop under about one twentieth of a core however slow a scan becomes.
This is a safety valve rather than a return of the adaptive cadence that was
removed earlier on this branch: that one added a penalty on top of the base so
the interval was never the configured value, and capped the penalty so the bound
broke down once scans got slow enough, whereas this leaves the interval at
exactly five seconds on every host we have measured, a scan at two thousand
processes taking tens of milliseconds against the two hundred and fifty the
floor would need to engage. Validated with a new test that drives a scan slow
enough to trip the floor and asserts the longer rest, verified to fail without
the change, and by reworking the test that pins the fixed interval to use a scan
short enough to stay inside the budget while still being long enough that a
penalty proportional to it would be obvious.
@grantseltzer
grantseltzer force-pushed the grantseltzer/fix-procscan-backoff branch from 2cb3ed4 to be6ee6c Compare August 10, 2026 12:02
… for a memfd

Searching a process' open descriptors for a tracer memfd read the whole
/proc/<pid>/fd directory into memory before examining any of it, which allocates
a string for every descriptor the process holds. That was tolerable when the
search ran three times in a process' lifetime, but the reconcile loop now runs
it on a backoff for the life of every Go process that has not published
metadata, and a service holding two hundred thousand descriptors turns each of
those into a ten megabyte allocation that is discarded immediately. The
directory is now read five hundred and twelve names at a time and each name is
checked as it arrives, so the search holds a fixed amount of memory and stops at
the first match instead of always paying for the whole table. procfs generates
the directory by walking the descriptor table in order, so the chunks arrive
lowest descriptor first, which is where a memfd published early in a process'
startup sits; the quick path that checks descriptor three still runs first and
is unchanged. Nothing about which processes are found changes, only what it
costs to find them. Validated with a new test, the first this function has had,
that pushes a memfd past the first chunk and asserts the search still finds it
at its exact path, along with a negative case, and verified to fail when the
search reads only one chunk.
rdesgroppes added a commit that referenced this pull request Aug 10, 2026
### What does this PR do?
Bump `agent_rpm_arm64`/`agent_suse_arm64`'s `max_on_disk_size` using
`quality-gates.exception-threshold-bump`'s fix from #54615: the only
two gates PR #54400 is actually short on headroom for.

Also align each RPM/SUSE twin gate pair's `max_on_wire_size` up to the
higher of the two (`agent_rpm_amd64_fips`, `agent_rpm_arm64_fips`,
`agent_suse_arm64`, `iot_agent_suse_amd64`): RPM and SUSE packages are
the same format, so a twin pair drifting apart is noise, not signal.

### Motivation
PR #54400 (an incident fix) is blocked by these two gates, both at
zero margin on `main` after #54204's Rust data-security check. A
rebase alone left it short by 34 KiB.

### Describe how you validated your changes
Confirmed against PR #54400's live metrics: it currently fails only
`agent_rpm_arm64`/`agent_suse_arm64` on-disk, by 34.26 KiB; against
these new thresholds, none of its 33 gates fail.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@grantseltzer
grantseltzer force-pushed the grantseltzer/fix-procscan-backoff branch from be6ee6c to cbff2c2 Compare August 10, 2026 14:27
gh-worker-dd-mergequeue-cf854d Bot pushed a commit that referenced this pull request Aug 10, 2026
### What does this PR do?
Bump `agent_rpm_arm64`/`agent_suse_arm64`'s `max_on_disk_size` using `quality-gates.exception-threshold-bump`'s fix from #54615: the only two gates PR #54400 is actually short on headroom for.

Also align each RPM/SUSE twin gate pair's `max_on_wire_size` up to the higher of the two (`agent_rpm_amd64_fips`, `agent_rpm_arm64_fips`, `agent_suse_arm64`, `iot_agent_suse_amd64`): RPM and SUSE packages are the same format, so a twin pair drifting apart is noise, not signal.

### Motivation
#54400 (an incident fix) is blocked by these two gates, both at zero margin on `main` after #54204's Rust data-security check.
A rebase alone left it short by 34 KiB.

### Describe how you validated your changes
Confirmed against #54400's live metrics: it currently fails only `agent_rpm_arm64`/`agent_suse_arm64` on-disk, by 34.26 KiB.
Against these new thresholds, none of its 33 gates fail.

Co-authored-by: regis.desgroppes <regis.desgroppes@datadoghq.com>
// Go binary.
checkGoExecutable func(path string) (bool, error)

goExecutables *lru.Cache[process.FileKey, bool]

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.

can you please add a comment on the relationship between goExecutables and candidates?
Is candidates only for Go processes? If so, spell that out in a comment.

),
})
}
func NewScanner(procfsRoot string, opts ...Option) *Scanner {

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.

nit: I see a single caller; consider hard-coding the options inside the constructor. Perhaps using options is not necessary.

// yet, whatever the reason.
type candidate struct {
// startTime distinguishes this process from a later one that reuses its pid.
startTime ticks

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.

Is dealing with ticks worth it? Particularly now that the Scanner also deals with durations expressed in normal units, could we use time.Time here for nextAttempt and seenAt? I can understand that ticks is useful for startTime maybe in order to deal with the process key and make it cheap to see if the process is newly discovered or not, but I'm guessing ticks are not useful beyond that.
Anyway, take it or leave it.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

That start time is purely to distinguish processes from one another, ticks make sense because that's what we get from procfs. No need to convert.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Ok you were right, I misunderstood your comment. Implemented.

…in maps

The scanner kept its live process set in a btree and its Go-binary verdicts in
an LRU, both instantiated over types that appear nowhere else in the binary. Go
stencils generics per GC shape, so a btree keyed on a named 16-byte struct emits
a fresh copy of every method and embeds the fully qualified shape name once per
symbol, which then lands in the pclntab, rodata and DWARF; that metadata rather
than the code itself accounted for most of a 63 KB growth in system-probe, which
pushed the arm64 agent package past a static quality gate that had almost no
headroom left. Neither structure needed what it was chosen for: both consumers
of the live set treat it as a set keyed by pid and never observe its order, and
the executable cache only needs a bound, which two generations of plain map give
for a kilobyte where the LRU cost thirty one. Dropping both leaves system-probe
30 KB smaller than main, since the btree instantiation the scanner used to pay
for is gone too. Validated by building system-probe before and after and diffing
per-symbol and per-section sizes, and by running the procscan and procsubscribe
tests under the race detector, whose snapshots now sort explicitly where they
previously leaned on btree ordering.
@grantseltzer
grantseltzer force-pushed the grantseltzer/fix-procscan-backoff branch from cbff2c2 to c64775e Compare August 10, 2026 17:18
@grantseltzer

Copy link
Copy Markdown
Member Author

/merge

@gh-worker-devflow-routing-ef8351

gh-worker-devflow-routing-ef8351 Bot commented Aug 10, 2026

Copy link
Copy Markdown

View all feedbacks in Devflow UI.

2026-08-10 17:39:54 UTC ℹ️ Start processing command /merge


2026-08-10 17:40:03 UTC ℹ️ MergeQueue: Pull request is not mergeable yet

It will be processed automatically as soon as GitHub reports it as mergeable. View in MergeQueue UI.

  • Run /code blockers to see what is blocking it.
  • Run /remove to cancel it.

2026-08-10 19:13:20 UTC ℹ️ MergeQueue: merge request added to the queue

The expected merge time in main is approximately 3h (p90).


2026-08-10 19:54:12 UTC 🚨 MergeQueue: This merge request is in error because of DDCI

DDCI pipeline didn't start (sourcing_failed)... Please retry.

DDCI Change Request: 7241086636128117129
You can get help from #ci-infra-support

@grantseltzer

Copy link
Copy Markdown
Member Author

/merge

@gh-worker-devflow-routing-ef8351

gh-worker-devflow-routing-ef8351 Bot commented Aug 10, 2026

Copy link
Copy Markdown

View all feedbacks in Devflow UI.

2026-08-10 20:32:04 UTC ℹ️ Start processing command /merge


2026-08-10 20:32:09 UTC ℹ️ MergeQueue: pull request added to the queue

The expected merge time in main is approximately 3h (p90).


2026-08-10 21:26:16 UTC ℹ️ MergeQueue: This merge request was merged

@grantseltzer grantseltzer added the backport/7.83.x Automatically create a backport PR to the 7.83.x branch once the PR is merged label Aug 10, 2026
@gh-worker-dd-mergequeue-cf854d
gh-worker-dd-mergequeue-cf854d Bot merged commit 623b04c into main Aug 10, 2026
395 checks passed
@gh-worker-dd-mergequeue-cf854d
gh-worker-dd-mergequeue-cf854d Bot deleted the grantseltzer/fix-procscan-backoff branch August 10, 2026 21:26
@temporal-github-worker-1

Copy link
Copy Markdown
Contributor

Backport evaluation: View Slack thread

@github-actions github-actions Bot added this to the 7.84.0 milestone Aug 10, 2026
gh-worker-dd-mergequeue-cf854d Bot pushed a commit that referenced this pull request Aug 12, 2026
Backport 623b04c from #54400.

 ___

### What does this PR do?

Changes how the live debugger finds processes to instrument. It used to look at  each process exactly three times, when the process turned 3, 100 and 1000 seconds old. If the tracer inside that process had not announced itself by the third look, that process was never instrumented again for as long as it ran. Now every process is checked repeatedly, with a growing gap between checks, until it is either instrumented or gone. A slow start is a delay of seconds instead of a permanent miss.

Two supporting changes come with it. Processes that are not Go programs are identified with a cheap check and set aside, so that looking at everything repeatedly stays affordable. And scans now run on a plain five second timer, where the old one stretched itself in proportion to how long the previous scan took, with no upper limit.

Includes benchmarks for the cost of a scan.
  
### Motivation

This came out of an investigation into a service that took roughly half an hour to upload its debugging symbols. Symbols are only uploaded once a service is being instrumented, so a discovery failure surfaces as an unexplained delay somewhere else entirely, which is what made it hard to track down.

The tracer announces itself at the very end of its own startup, behind a network call that can hang for ten seconds. The first look happened a few seconds after the process started, so the tracer could not win that race, and the design gave it only two more chances ever. The same investigation turned up a second problem: because the timer grew with scan duration and had no ceiling, a single slow scan could push the next one out far enough to swallow two of the three chances a process ever got.

### Describe how you validated your changes

Extended the existing table-driven scanner tests to cover the retry schedule, a tracer that only announces itself after many attempts, agent restart, process ID reuse, permission failures, processes that are not Go programs, and the fixed timer.

Added benchmarks, since the new loop looks at every process every time. On a host with two thousand processes, an ordinary scan costs about 0.44% of one core, and the first scan after a restart costs about 2% of a core for that one scan.

### Additional Notes

A process that starts as a shell script and later replaces itself with the real service keeps the same process ID, so if we happen to look at it while it is still the script, we write it off along with the service it becomes if it's not Go. Container enrypoints happen on proc start so this is not an issue except the case of a proc running as a script that does set up and does an exec. I don't think that really matters for support.

Co-authored-by: sabrina.lu <sabrina.lu@datadoghq.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

backport/7.83.x Automatically create a backport PR to the 7.83.x branch once the PR is merged changelog/no-changelog No changelog entry needed component/system-probe internal Identify a non-fork PR long review PR is complex, plan time to review it qa/done QA done before merge and regressions are covered by tests team/agent-build team/debugger team/ebpf-platform

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants