Skip to content

Commit c8cdf60

Browse files
edewataclaude
andcommitted
Add test to detect memory leak
Add script to compare Java heap snapshots for memory leak analysis Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
1 parent 1de2daa commit c8cdf60

3 files changed

Lines changed: 279 additions & 0 deletions

File tree

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
name: Server memory leak test
2+
3+
on: workflow_call
4+
5+
env:
6+
DS_IMAGE: ${{ vars.DS_IMAGE || 'quay.io/389ds/dirsrv' }}
7+
8+
jobs:
9+
# docs/installation/ca/Installing_CA.md
10+
test:
11+
name: Test
12+
runs-on: ubuntu-latest
13+
env:
14+
SHARED: /tmp/workdir/pki
15+
steps:
16+
- name: Clone repository
17+
uses: actions/checkout@v7
18+
19+
- name: Retrieve PKI images
20+
uses: actions/cache@v5
21+
with:
22+
key: pki-images-${{ github.sha }}
23+
path: pki-images.tar
24+
25+
- name: Load PKI images
26+
run: docker load --input pki-images.tar
27+
28+
- name: Create network
29+
run: docker network create example
30+
31+
- name: Set up DS container
32+
run: |
33+
tests/bin/ds-create.sh \
34+
--image=${{ env.DS_IMAGE }} \
35+
--hostname=ds.example.com \
36+
--network=example \
37+
--network-alias=ds.example.com \
38+
--password=Secret.123 \
39+
ds
40+
41+
- name: Set up PKI container
42+
run: |
43+
tests/bin/runner-init.sh \
44+
--hostname=pki.example.com \
45+
--network=example \
46+
--network-alias=pki.example.com \
47+
pki
48+
49+
docker exec pki rpm -qa | grep openjdk
50+
docker exec pki dnf install -y java-25-openjdk-devel
51+
52+
- name: Install CA
53+
run: |
54+
docker exec pki pkispawn \
55+
-f /usr/share/pki/server/examples/installation/ca.cfg \
56+
-s CA \
57+
-D pki_ds_url=ldap://ds.example.com:3389 \
58+
--debug \
59+
> >(tee stdout) 2> >(tee stderr >&2)
60+
61+
PID=$(docker exec pki ps -C java -o pid --no-headers | awk '{print $1;}')
62+
echo "PID=$PID"
63+
echo "$PID" > java.pid
64+
65+
- name: Check heap info
66+
run: |
67+
PID=$(cat java.pid)
68+
69+
docker exec pki jhsdb jmap --heap --pid $PID
70+
71+
- name: Check object counts before load test
72+
run: |
73+
PID=$(cat java.pid)
74+
75+
docker exec pki jhsdb jmap --histo --pid $PID \
76+
| tail -n +9 \
77+
| grep -E 'org.dogtagpki|org.mozilla' \
78+
| awk '{print $4, $2}' \
79+
| sort \
80+
| tee objects.before
81+
82+
- name: Run load test
83+
run: |
84+
PID=$(cat java.pid)
85+
86+
docker exec pki pki-server cert-export \
87+
--cert-file ca_signing.crt \
88+
ca_signing
89+
90+
docker exec pki pki nss-cert-import \
91+
--cert ca_signing.crt \
92+
--trust CT,C,C \
93+
ca_signing
94+
95+
# run garbage collection before test
96+
docker exec pki jcmd $PID GC.run
97+
98+
# run pki info command 100 times
99+
for i in $(seq 1 100); do
100+
docker exec pki pki info
101+
done
102+
103+
# run garbage collection after test
104+
docker exec pki jcmd $PID GC.run
105+
106+
- name: Check object counts after load test
107+
run: |
108+
PID=$(cat java.pid)
109+
110+
# get object class names and number of instances
111+
docker exec pki jhsdb jmap --histo --pid $PID \
112+
| tail -n +9 \
113+
| grep -E 'org.dogtagpki|org.mozilla' \
114+
| awk '{print $4, $2}' \
115+
| sort \
116+
| tee objects.after
117+
118+
- name: Check object count growths
119+
run: |
120+
tests/bin/check-memory-leak.py objects.before objects.after
121+
122+
# if the object count increased by 100 times (i.e. matching
123+
# the load test) it's likely that the object is not released
124+
# properly for garbage collection and causing a memory leak
125+
126+
# TODO: fail the test if there are objects that increased by
127+
# 100 times
128+
129+
- name: Remove CA
130+
run: |
131+
docker exec pki pkidestroy \
132+
-s CA \
133+
--debug \
134+
> >(tee stdout) 2> >(tee stderr >&2)
135+
136+
- name: Check DS server systemd journal
137+
if: always()
138+
run: |
139+
docker exec ds journalctl -x --no-pager -u dirsrv@localhost.service
140+
141+
- name: Check DS container logs
142+
if: always()
143+
run: |
144+
docker logs ds
145+
146+
- name: Check PKI server systemd journal
147+
if: always()
148+
run: |
149+
docker exec pki journalctl -x --no-pager -u pki-tomcatd@pki-tomcat.service
150+
151+
- name: Check PKI server access log
152+
if: always()
153+
run: |
154+
docker exec pki find /var/log/pki/pki-tomcat -name "localhost_access_log.*" -exec cat {} \;
155+
156+
- name: Check CA debug log
157+
if: always()
158+
run: |
159+
docker exec pki find /var/lib/pki/pki-tomcat/logs/ca -name "debug.*" -exec cat {} \;

.github/workflows/server-tests.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,11 @@ jobs:
5353
needs: build
5454
uses: ./.github/workflows/server-https-kryoptic-pqc-test.yml
5555

56+
server-memory-leak-test:
57+
name: Server memory leak
58+
needs: build
59+
uses: ./.github/workflows/server-memory-leak-test.yml
60+
5661
server-backup-test:
5762
name: Server backup
5863
needs: build

tests/bin/check-memory-leak.py

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
#!/usr/bin/python3
2+
3+
import argparse
4+
import sys
5+
from typing import Dict, Tuple, List
6+
7+
8+
def parse_input(filename: str) -> Dict[str, int]:
9+
"""Parse input file and return a dictionary of class name to object count."""
10+
data = {}
11+
try:
12+
with open(filename, 'r') as f:
13+
for line in f:
14+
line = line.strip()
15+
if not line:
16+
continue
17+
parts = line.split()
18+
if len(parts) != 2:
19+
print(f"Error: File '{filename}' line {line_num} does not contain exactly 2 columns", file=sys.stderr)
20+
sys.exit(1)
21+
class_name, count = parts
22+
try:
23+
data[class_name] = int(count)
24+
except ValueError:
25+
print(f"Error: File '{filename}' line {line_num} has invalid count value: {count}", file=sys.stderr)
26+
sys.exit(1)
27+
except FileNotFoundError:
28+
print(f"Error: File '{filename}' not found", file=sys.stderr)
29+
sys.exit(1)
30+
except Exception as e:
31+
print(f"Error reading file '{filename}': {e}", file=sys.stderr)
32+
sys.exit(1)
33+
return data
34+
35+
36+
def calculate_growth(before: Dict[str, int], after: Dict[str, int]) -> List[Tuple[str, int, int, float]]:
37+
"""Calculate the growth between two snapshots.
38+
39+
Returns a list of tuples: (class_name, orig_count, growth, growth_pct)
40+
"""
41+
growth_data = []
42+
43+
# Find all classes present in either files
44+
all_classes = set(before.keys()) | set(after.keys())
45+
46+
for class_name in all_classes:
47+
orig_count = before.get(class_name, 0)
48+
growth = after.get(class_name, 0) - orig_count
49+
50+
# Calculate percentage growth
51+
if orig_count == 0:
52+
# New class appeared in second file
53+
if growth > 0:
54+
growth_pct = float('inf')
55+
else:
56+
continue
57+
else:
58+
growth_pct = (growth / orig_count) * 100
59+
60+
# Only include classes that have grown
61+
if growth > 0:
62+
growth_data.append((class_name, orig_count, growth, growth_pct))
63+
64+
return growth_data
65+
66+
67+
def main():
68+
parser = argparse.ArgumentParser(
69+
description='Compare object counts to identify potential memory leak'
70+
)
71+
parser.add_argument('before', help='First input file')
72+
parser.add_argument('after', help='Second input file')
73+
74+
args = parser.parse_args()
75+
76+
# Parse both params
77+
before = parse_input(args.before)
78+
after = parse_input(args.after)
79+
80+
if not before:
81+
print("Error: First input file is empty or invalid", file=sys.stderr)
82+
sys.exit(1)
83+
84+
if not after:
85+
print("Error: Second input file is empty or invalid", file=sys.stderr)
86+
sys.exit(1)
87+
88+
# Calculate growth
89+
growth_data = calculate_growth(before, after)
90+
91+
if not growth_data:
92+
print("No objects with increased counts found")
93+
return
94+
95+
# Sort by growth percentage (descending)
96+
growth_data.sort(key=lambda x: (x[3] != float('inf'), x[3]), reverse=True)
97+
98+
# Display growth
99+
print(f"{'Class Name':<60} {'Original':>12} {'Growth':>12} {'Percentage':>12}")
100+
print("=" * 100)
101+
102+
for i, (class_name, orig_count, growth, growth_pct) in enumerate(growth_data):
103+
if growth_pct == float('inf'):
104+
growth_str = "NEW"
105+
else:
106+
growth_str = f"{growth_pct:+.1f}%"
107+
108+
# Truncate long class names
109+
display_name = class_name[:60] if len(class_name) <= 60 else class_name[:57] + "..."
110+
111+
print(f"{display_name:<60} {orig_count:>12,} {growth:>12,} {growth_str:>12}")
112+
113+
114+
if __name__ == '__main__':
115+
main()

0 commit comments

Comments
 (0)