Skip to content

Commit 96a1ac5

Browse files
committed
fix(subprocess): count worker descendants in their own process group in tree RSS
Addresses CodeRabbit review on PR #34 (src/foundation/subprocess.c). The index worker's RSS probe summed only processes whose process group matched the worker's. Every child spawned through cbm_subprocess gets its own group (setpgid on Linux, POSIX_SPAWN_SETPGROUP on macOS), including `git ls-files -z` from discover.c. Discovery memory on a large Git index was therefore invisible to the index_max_rss_mb watchdog. Linux: the /proc scan now also reads ppid, collects processes into a bounded array (262144 entries, fails closed past it), and marks the worker group plus every descendant by parent pid (sorted + bsearch, repeated until no change). Each process is summed once; the existing unreadable-root, gone and empty outcomes are unchanged. macOS: the PROC_PGRP_ONLY listing is expanded with PROC_PPID_ONLY children of each collected pid, deduplicated, capped at 65536 and failing closed. Not compiled or run in this change; Linux only was verified. Windows is unchanged: the Job Object already covers descendants. Test: subprocess_tree_rss_counts_descendants_in_their_own_process_group spawns a real tree whose grandchild moves to its own group and holds 128 MiB, and asserts the measured total grows by at least 96 MiB. The __cbm_rss_tree_root helper role gains an optional `detach` argument; the detached holder exits when its parent disappears (alarm(120) backstop).
1 parent 0f1927f commit 96a1ac5

3 files changed

Lines changed: 219 additions & 25 deletions

File tree

‎src/foundation/subprocess.c‎

Lines changed: 166 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -591,7 +591,7 @@ static cbm_proc_tree_rss_status_t cbm_subprocess_tree_rss_platform(cbm_subproces
591591
* all-process scan cannot decide membership: proc_pidinfo answers EPERM for
592592
* every process of another user, so "not in our group" and "in our group but
593593
* unreadable" would look the same. Returns the member count, or -1. */
594-
static int cbm_tree_rss_list_group(pid_t pgid, pid_t **pids_out) {
594+
static int cbm_tree_rss_list_pids(uint32_t type, uint32_t arg, pid_t **pids_out) {
595595
int capacity = 64;
596596
pid_t *pids = NULL;
597597
for (;;) {
@@ -602,8 +602,7 @@ static int cbm_tree_rss_list_group(pid_t pgid, pid_t **pids_out) {
602602
}
603603
pids = grown;
604604
errno = 0;
605-
int bytes =
606-
proc_listpids(PROC_PGRP_ONLY, (uint32_t)pgid, pids, capacity * (int)sizeof(*pids));
605+
int bytes = proc_listpids(type, arg, pids, capacity * (int)sizeof(*pids));
607606
if (bytes < 0 || (bytes == 0 && errno != 0)) {
608607
cbm_free(CBM_MEM_CLASS_OTHER, pids);
609608
return -1;
@@ -621,6 +620,63 @@ static int cbm_tree_rss_list_group(pid_t pgid, pid_t **pids_out) {
621620
}
622621
}
623622

623+
/* Bound on the processes one probe retains; past it the probe fails closed. */
624+
enum { CBM_APPLE_PROBE_MAX_PROCS = 65536 };
625+
626+
/* The worker's process group plus every descendant by parent pid. Children
627+
* placed in a group of their own (git discovery) are not in the group list.
628+
* Pids already collected are skipped, so nothing is counted twice. Returns the
629+
* count, or -1. */
630+
static int cbm_tree_rss_list_tree(pid_t pgid, pid_t **pids_out) {
631+
pid_t *tree = NULL;
632+
int count = cbm_tree_rss_list_pids(PROC_PGRP_ONLY, (uint32_t)pgid, &tree);
633+
if (count < 0) {
634+
return -1;
635+
}
636+
int capacity = count > 0 ? count : 1;
637+
for (int index = 0; index < count; index++) {
638+
pid_t *children = NULL;
639+
int child_count = tree[index] > 0 ? cbm_tree_rss_list_pids(PROC_PPID_ONLY,
640+
(uint32_t)tree[index], &children)
641+
: 0;
642+
if (child_count < 0) {
643+
/* ESRCH-style races surface as an empty listing; a real error
644+
* must not under-report the tree. */
645+
cbm_free(CBM_MEM_CLASS_OTHER, tree);
646+
return -1;
647+
}
648+
for (int child = 0; child < child_count; child++) {
649+
bool known = children[child] <= 0;
650+
for (int seen = 0; !known && seen < count; seen++) {
651+
known = tree[seen] == children[child];
652+
}
653+
if (known) {
654+
continue;
655+
}
656+
if (count >= CBM_APPLE_PROBE_MAX_PROCS) {
657+
cbm_free(CBM_MEM_CLASS_OTHER, children);
658+
cbm_free(CBM_MEM_CLASS_OTHER, tree);
659+
return -1;
660+
}
661+
if (count == capacity) {
662+
pid_t *grown =
663+
cbm_realloc(CBM_MEM_CLASS_OTHER, tree, (size_t)capacity * 2 * sizeof(*tree));
664+
if (!grown) {
665+
cbm_free(CBM_MEM_CLASS_OTHER, children);
666+
cbm_free(CBM_MEM_CLASS_OTHER, tree);
667+
return -1;
668+
}
669+
tree = grown;
670+
capacity *= 2;
671+
}
672+
tree[count++] = children[child];
673+
}
674+
cbm_free(CBM_MEM_CLASS_OTHER, children);
675+
}
676+
*pids_out = tree;
677+
return count;
678+
}
679+
624680
static int cbm_tree_rss_member_rusage(pid_t pid, bool root, struct rusage_info_v2 *usage) {
625681
#ifdef CBM_ENABLE_TEST_SEAMS
626682
int fault = cbm_tree_rss_member_fault(root);
@@ -637,7 +693,7 @@ static int cbm_tree_rss_member_rusage(pid_t pid, bool root, struct rusage_info_v
637693
static cbm_proc_tree_rss_status_t cbm_subprocess_tree_rss_platform(cbm_subprocess_t *process,
638694
uint64_t *rss_bytes) {
639695
pid_t *pids = NULL;
640-
int count = cbm_tree_rss_list_group(process->pgid, &pids);
696+
int count = cbm_tree_rss_list_tree(process->pgid, &pids);
641697
if (count < 0) {
642698
return CBM_PROC_TREE_RSS_ERROR;
643699
}
@@ -730,8 +786,8 @@ static cbm_linux_stat_t cbm_linux_proc_stat_line(const char *pid_name, bool root
730786
return CBM_LINUX_STAT_OK;
731787
}
732788

733-
static cbm_linux_stat_t cbm_linux_proc_stat(const char *pid_name, bool root, pid_t *group,
734-
int64_t *rss_pages) {
789+
static cbm_linux_stat_t cbm_linux_proc_stat(const char *pid_name, bool root, pid_t *parent,
790+
pid_t *group, int64_t *rss_pages) {
735791
char stat_line[4096];
736792
cbm_linux_stat_t status =
737793
cbm_linux_proc_stat_line(pid_name, root, stat_line, sizeof(stat_line));
@@ -746,16 +802,20 @@ static cbm_linux_stat_t cbm_linux_proc_stat(const char *pid_name, bool root, pid
746802
char *save = NULL;
747803
char *token = strtok_r(command_end + 2, " ", &save);
748804
int field = 3;
805+
bool have_parent = false;
749806
bool have_group = false;
750807
bool have_rss = false;
751808
while (token) {
752-
if (field == 5 || field == 24) {
809+
if (field == 4 || field == 5 || field == 24) {
753810
char *end = NULL;
754811
long long value = strtoll(token, &end, 10);
755812
if (!end || *end != '\0') {
756813
return CBM_LINUX_STAT_UNREADABLE;
757814
}
758-
if (field == 5) {
815+
if (field == 4) {
816+
*parent = (pid_t)value;
817+
have_parent = true;
818+
} else if (field == 5) {
759819
*group = (pid_t)value;
760820
have_group = true;
761821
} else {
@@ -767,7 +827,76 @@ static cbm_linux_stat_t cbm_linux_proc_stat(const char *pid_name, bool root, pid
767827
token = strtok_r(NULL, " ", &save);
768828
field++;
769829
}
770-
return have_group && have_rss ? CBM_LINUX_STAT_OK : CBM_LINUX_STAT_UNREADABLE;
830+
return have_parent && have_group && have_rss ? CBM_LINUX_STAT_OK : CBM_LINUX_STAT_UNREADABLE;
831+
}
832+
833+
/* One scanned process. `counted` is set once it is known to belong to the
834+
* worker tree. */
835+
typedef struct {
836+
pid_t pid;
837+
pid_t parent;
838+
pid_t group;
839+
uint64_t bytes;
840+
bool counted;
841+
} cbm_linux_proc_t;
842+
843+
/* Bound on the processes one probe retains. Past it the probe fails closed
844+
* instead of reporting a total that may miss part of the tree. */
845+
enum { CBM_LINUX_PROBE_MAX_PROCS = 262144, CBM_LINUX_PROBE_INITIAL_PROCS = 512 };
846+
847+
static bool cbm_linux_probe_push(cbm_linux_proc_t **procs, size_t *count, size_t *capacity,
848+
const cbm_linux_proc_t *proc) {
849+
if (*count == *capacity) {
850+
if (*capacity >= CBM_LINUX_PROBE_MAX_PROCS) {
851+
return false;
852+
}
853+
size_t grown_capacity = *capacity ? *capacity * 2 : CBM_LINUX_PROBE_INITIAL_PROCS;
854+
cbm_linux_proc_t *grown =
855+
cbm_realloc(CBM_MEM_CLASS_OTHER, *procs, grown_capacity * sizeof(**procs));
856+
if (!grown) {
857+
return false;
858+
}
859+
*procs = grown;
860+
*capacity = grown_capacity;
861+
}
862+
(*procs)[(*count)++] = *proc;
863+
return true;
864+
}
865+
866+
static int cbm_linux_probe_by_pid(const void *left, const void *right) {
867+
pid_t a = ((const cbm_linux_proc_t *)left)->pid;
868+
pid_t b = ((const cbm_linux_proc_t *)right)->pid;
869+
return (a > b) - (a < b);
870+
}
871+
872+
/* Mark every process of the worker tree: members of its process group, plus
873+
* any descendant by parent pid. Descendants can sit in a group of their own
874+
* (git discovery children are spawned with setpgid), so the group alone would
875+
* miss them. `procs` is sorted by pid, so a parent is found by binary search
876+
* and each pass is O(n log n); passes repeat until no process is added (at
877+
* most the tree depth). A process is counted once because the flag is checked
878+
* before it is set. */
879+
static void cbm_linux_probe_mark_tree(cbm_linux_proc_t *procs, size_t count, pid_t pgid) {
880+
qsort(procs, count, sizeof(*procs), cbm_linux_probe_by_pid);
881+
for (size_t index = 0; index < count; index++) {
882+
procs[index].counted = procs[index].group == pgid;
883+
}
884+
bool changed = true;
885+
while (changed) {
886+
changed = false;
887+
for (size_t index = 0; index < count; index++) {
888+
if (procs[index].counted || procs[index].parent <= 1) {
889+
continue;
890+
}
891+
cbm_linux_proc_t key = {.pid = procs[index].parent};
892+
const cbm_linux_proc_t *parent =
893+
bsearch(&key, procs, count, sizeof(*procs), cbm_linux_probe_by_pid);
894+
if (parent && parent->counted) {
895+
procs[index].counted = true;
896+
changed = true;
897+
}
898+
}
899+
}
771900
}
772901

773902
static cbm_proc_tree_rss_status_t cbm_subprocess_tree_rss_platform(cbm_subprocess_t *process,
@@ -782,36 +911,54 @@ static cbm_proc_tree_rss_status_t cbm_subprocess_tree_rss_platform(cbm_subproces
782911
return CBM_PROC_TREE_RSS_ERROR;
783912
}
784913

785-
uint64_t total = 0;
786-
int measured = 0;
914+
cbm_linux_proc_t *procs = NULL;
915+
size_t count = 0;
916+
size_t capacity = 0;
787917
bool failed = false;
788918
struct dirent *entry;
789919
while (!failed && (entry = readdir(proc)) != NULL) {
790920
if (!cbm_proc_pid_name(entry->d_name)) {
791921
continue;
792922
}
923+
pid_t parent = 0;
793924
pid_t group = 0;
794925
int64_t pages = 0;
795-
bool root_entry = strtol(entry->d_name, NULL, 10) == (long)process->pid;
796-
cbm_linux_stat_t status = cbm_linux_proc_stat(entry->d_name, root_entry, &group, &pages);
926+
long pid = strtol(entry->d_name, NULL, 10);
927+
bool root_entry = pid == (long)process->pid;
928+
cbm_linux_stat_t status =
929+
cbm_linux_proc_stat(entry->d_name, root_entry, &parent, &group, &pages);
797930
if (status != CBM_LINUX_STAT_OK) {
798931
failed = root_entry || status != CBM_LINUX_STAT_GONE;
799932
continue;
800933
}
801-
if (group != process->pgid || pages < 0) {
934+
if (pages < 0) {
802935
continue;
803936
}
804937
uint64_t page_count = (uint64_t)pages;
805-
uint64_t bytes = page_count > UINT64_MAX / (uint64_t)page_size
806-
? UINT64_MAX
807-
: page_count * (uint64_t)page_size;
808-
cbm_rss_add_saturated(&total, bytes);
809-
measured++;
938+
cbm_linux_proc_t item = {.pid = (pid_t)pid,
939+
.parent = parent,
940+
.group = group,
941+
.bytes = page_count > UINT64_MAX / (uint64_t)page_size
942+
? UINT64_MAX
943+
: page_count * (uint64_t)page_size};
944+
failed = !cbm_linux_probe_push(&procs, &count, &capacity, &item);
810945
}
811946
(void)closedir(proc);
812947
if (failed) {
948+
cbm_free(CBM_MEM_CLASS_OTHER, procs);
813949
return CBM_PROC_TREE_RSS_ERROR;
814950
}
951+
952+
cbm_linux_probe_mark_tree(procs, count, process->pgid);
953+
uint64_t total = 0;
954+
int measured = 0;
955+
for (size_t index = 0; index < count; index++) {
956+
if (procs[index].counted) {
957+
cbm_rss_add_saturated(&total, procs[index].bytes);
958+
measured++;
959+
}
960+
}
961+
cbm_free(CBM_MEM_CLASS_OTHER, procs);
815962
if (measured == 0) {
816963
return CBM_PROC_TREE_RSS_EMPTY;
817964
}

‎tests/test_main.c‎

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2044,7 +2044,7 @@ static int tf_role_env_isolation(int argc, char **argv) {
20442044
* publishes its pid by an atomic rename once every page is touched, then
20452045
* blocks until it is killed. The root exits when its descendant dies, so a
20462046
* failed setup ends the tree instead of leaving the test waiting. */
2047-
static int tf_rss_tree_hold(const char *ready_path, const char *mib_text) {
2047+
static int tf_rss_tree_hold(const char *ready_path, const char *mib_text, bool own_group) {
20482048
char *end = NULL;
20492049
long mib = strtol(mib_text, &end, 10);
20502050
if (!end || *end != '\0' || mib < 0 || mib > 1024) {
@@ -2074,6 +2074,18 @@ static int tf_rss_tree_hold(const char *ready_path, const char *mib_text) {
20742074
}
20752075
volatile unsigned char keep = block ? block[bytes - 1] : 0;
20762076
(void)keep;
2077+
#ifndef _WIN32
2078+
if (own_group) {
2079+
/* Nothing kills a process outside the worker's group, so leave when
2080+
* the root (the parent) is gone, with an alarm as a backstop. */
2081+
pid_t parent = getppid();
2082+
(void)alarm(120);
2083+
while (getppid() == parent) {
2084+
cbm_usleep(10000);
2085+
}
2086+
return 0;
2087+
}
2088+
#endif
20772089
for (;;) {
20782090
#ifdef _WIN32
20792091
Sleep(INFINITE);
@@ -2084,11 +2096,13 @@ static int tf_rss_tree_hold(const char *ready_path, const char *mib_text) {
20842096
}
20852097

20862098
static int tf_role_rss_tree_hold(int argc, char **argv) {
2087-
return argc == 4 ? tf_rss_tree_hold(argv[2], argv[3]) : -1;
2099+
return argc == 4 ? tf_rss_tree_hold(argv[2], argv[3], false) : -1;
20882100
}
20892101

20902102
static int tf_role_rss_tree_root(int argc, char **argv) {
2091-
if (argc != 4) {
2103+
/* A fifth argument "detach" puts the descendant in a process group of its
2104+
* own, as git discovery children are. */
2105+
if (argc != 4 && argc != 5) {
20922106
return -1;
20932107
}
20942108
#ifdef _WIN32
@@ -2123,7 +2137,10 @@ static int tf_role_rss_tree_root(int argc, char **argv) {
21232137
return 85;
21242138
}
21252139
if (child == 0) {
2126-
_exit(tf_rss_tree_hold(argv[2], argv[3]));
2140+
if (argc == 5 && setpgid(0, 0) != 0) {
2141+
_exit(86);
2142+
}
2143+
_exit(tf_rss_tree_hold(argv[2], argv[3], argc == 5));
21272144
}
21282145
int status = 0;
21292146
while (waitpid(child, &status, 0) < 0 && errno == EINTR) {}

‎tests/test_subprocess.c‎

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -885,7 +885,7 @@ static bool rss_tree_finish(cbm_subprocess_t *process, rss_tree_sample_t *sample
885885

886886
/* Measure one tree whose descendant holds `mib` MiB, with every non-root
887887
* member's measurement failing with `member_fault` (0 = real calls). */
888-
static rss_tree_sample_t rss_tree_sample(int mib, int member_fault) {
888+
static rss_tree_sample_t rss_tree_sample_in(int mib, int member_fault, bool own_group) {
889889
rss_tree_sample_t sample = {.status = CBM_PROC_TREE_RSS_ERROR,
890890
.final_status = CBM_PROC_TREE_RSS_ERROR,
891891
.final_rss = UINT64_MAX};
@@ -898,7 +898,12 @@ static rss_tree_sample_t rss_tree_sample(int mib, int member_fault) {
898898
return sample;
899899
}
900900
(void)snprintf(mib_text, sizeof(mib_text), "%d", mib);
901-
const char *argv[] = {cbm_test_program_path, "__cbm_rss_tree_root", ready_path, mib_text, NULL};
901+
const char *argv[] = {cbm_test_program_path,
902+
"__cbm_rss_tree_root",
903+
ready_path,
904+
mib_text,
905+
own_group ? "detach" : NULL,
906+
NULL};
902907
cbm_proc_opts_t opts = {0};
903908
opts.bin = cbm_test_program_path;
904909
opts.argv = argv;
@@ -918,6 +923,10 @@ static rss_tree_sample_t rss_tree_sample(int mib, int member_fault) {
918923
return sample;
919924
}
920925

926+
static rss_tree_sample_t rss_tree_sample(int mib, int member_fault) {
927+
return rss_tree_sample_in(mib, member_fault, false);
928+
}
929+
921930
/* Only the DESCENDANT allocates, so the difference between two otherwise
922931
* identical trees is memory the probe can only have counted by measuring past
923932
* the root. 128 MiB touched against a 96 MiB floor leaves room for allocator
@@ -945,6 +954,26 @@ TEST(subprocess_tree_rss_measures_contained_descendants) {
945954
PASS();
946955
}
947956

957+
/* git discovery children run in a process group of their own. The probe must
958+
* still count them: the memory is the worker's, and a group-only probe would
959+
* let it grow past the limit unseen. POSIX only (a Job Object has no groups). */
960+
TEST(subprocess_tree_rss_counts_descendants_in_their_own_process_group) {
961+
#ifdef _WIN32
962+
SKIP_PLATFORM("process groups are POSIX; the Job Object probe already covers descendants");
963+
#else
964+
rss_tree_sample_t idle = rss_tree_sample_in(0, 0, true);
965+
rss_tree_sample_t loaded = rss_tree_sample_in(128, 0, true);
966+
ASSERT_TRUE(idle.ready);
967+
ASSERT_EQ(idle.status, CBM_PROC_TREE_RSS_OK);
968+
ASSERT_TRUE(loaded.ready);
969+
ASSERT_EQ(loaded.status, CBM_PROC_TREE_RSS_OK);
970+
ASSERT_TRUE(loaded.rss >= idle.rss + 96 * RSS_TREE_MIB);
971+
ASSERT_TRUE(idle.quiesced);
972+
ASSERT_TRUE(loaded.quiesced);
973+
PASS();
974+
#endif
975+
}
976+
948977
/* A live member the platform will not let us measure must fail the probe: a
949978
* total without it would under-report the tree. */
950979
TEST(subprocess_tree_rss_fails_when_a_live_member_cannot_be_measured) {
@@ -2817,6 +2846,7 @@ SUITE(subprocess) {
28172846
RUN_TEST(subprocess_run_null_bin_rejected);
28182847
RUN_TEST(subprocess_spawn_returns_while_child_is_running);
28192848
RUN_TEST(subprocess_tree_rss_measures_contained_descendants);
2849+
RUN_TEST(subprocess_tree_rss_counts_descendants_in_their_own_process_group);
28202850
RUN_TEST(subprocess_tree_rss_fails_when_a_live_member_cannot_be_measured);
28212851
RUN_TEST(subprocess_tree_rss_skips_a_member_that_exited_mid_probe);
28222852
RUN_TEST(subprocess_natural_completion_is_cached_across_polls);

0 commit comments

Comments
 (0)