/* * test_jobj_hardening.c -- unit tests for job-array-single-wal-record * Requirement 10 (jobj.c hardening). Each test exercises one of the five * acceptance criteria; per design.md, all five are general jobj.c * correctness fixes the existing single-job path benefits from too -- * none of this depends on the array-submission feature itself. */ #include #include #include #include #include #include #include "jobj.h" #include "kvmgr.h" #include "misc.h" #include "utils.h" #include "xce/types.h" #include "xstring.h" static int g_hard_xstring_ready = 0; static int g_hard_jobobj_ready = 0; static void hard_ensure_ready(void) { if (!g_hard_xstring_ready) { TEST_ASSERT_TRUE(xstring_table_init(64) >= 0); g_hard_xstring_ready = 1; } if (!g_hard_jobobj_ready) { TEST_ASSERT_EQUAL_INT(0, job_obj_init(64, 64)); g_hard_jobobj_ready = 1; } } static job_submit_data_t hard_make_submit(const char* job_name) { job_submit_data_t s = { .queue = "xce", .user = "rshen", .mintasks = 1, .maxtasks = 1, .start_info = NULL, .job_name = (char*)job_name, .project_name = "default", .user_group = NULL, .job_description = "d", .resreq = "type == any" }; return s; } /* ------------------------------------------------------------------- */ /* Requirement 10.2 -- job_obj_init() is idempotent without an */ /* intervening job_obj_fini(). */ /* ------------------------------------------------------------------- */ /* The previously observed makeJobsub() NULL-with-EINVAL-for-a-valid-src * failure was root-caused to job_obj_init() being non-idempotent: every * controller unit-test file that constructs jobs calls job_obj_init() * from its own setUp() (confirmed via src/controller/meson.build -- all * such test files are linked into the single controller_unit_test * executable and run in one process), each guarded only by its own * file-local static flag, none of which coordinate with each other. * Across a full test run job_obj_init() was therefore re-entered many * times with no intervening job_obj_fini(). This proves job_obj_init() * is now safe under exactly that pattern, and that construction still * works correctly (interning still functions) afterward -- not just * "didn't crash". */ static void test_job_obj_init_is_idempotent_without_fini(void) { int rc; hard_ensure_ready(); rc = job_obj_init(16, 16); TEST_ASSERT_EQUAL_INT(0, rc); rc = job_obj_init(64, 64); TEST_ASSERT_EQUAL_INT(0, rc); rc = job_obj_init(5381, 5381); TEST_ASSERT_EQUAL_INT(0, rc); job_submit_data_t submit1 = hard_make_submit("J-hard-10-2"); job_submit_data_t* s2 = makeJobsub(&submit1); job_submit_data_t* s3 = makeJobsub(&submit1); TEST_ASSERT_NOT_NULL(s2); TEST_ASSERT_TRUE(s2 == s3); } /* ------------------------------------------------------------------- */ /* Requirement 10.1 -- job_init() propagates a makeJobsub() failure */ /* instead of storing NULL and reporting success. */ /* ------------------------------------------------------------------- */ /* Forces makeJobsub() to fail the same way the originally observed bug * did -- via job_obj_fini() leaving jobsub_table not (yet) re-initialized * for this generation -- except now safely and deterministically: * makeJobsub()'s Requirement 10.2 guard detects this and fails cleanly * (NULL, errno=ENODEV) instead of touching a torn-down table, which is * what makes this test safe to run in-process alongside every other test * file regardless of what state they left jobj.c's module globals in. */ static void test_job_init_propagates_makeJobsub_failure(void) { kvmgr_params_t jobmgr_params = { .key_type = KVMGR_KEY_INT, .nslots = 32, .delta = 32, /* job_t has .destroy = NULL (jobj.c) -- * teardown is wired only through its * reference interface (job_dereference). * The kvmgr's datakiller must therefore * be dereference(), not the generic * destroy(), or job_destroy() never * runs and every raw-heap MOVE_PTR * field (e.g. accounts) silently leaks * on manager teardown. */ .datakiller = dereference }; var jobmgr; job_submit_data_t submit1 = hard_make_submit("J-hard-10-1"); xce_job_t job1 = { .submit = &submit1, .jobid = 90001 }; job_t* j2 = NULL; hard_ensure_ready(); jobmgr = makeInstance(&xkvmgr_type, &jobmgr_params); TEST_ASSERT_TRUE(jobmgr != NULL); /* Force the "not (yet) initialized" state this test needs. */ job_obj_fini(); g_hard_jobobj_ready = 0; j2 = makeJob(jobmgr, &job1); /* The bug: the original job_init() stored makeJobsub()'s NULL return * into dst->job.submit and still returned success (1), so makeJob() * would incorrectly hand back a non-NULL job_t with a NULL * job.submit. The fix must fail construction outright instead. */ TEST_ASSERT_NULL(j2); destroy(jobmgr); /* Restore module state for every test that runs after this one. */ hard_ensure_ready(); /* Known, out-of-boundary residual: under ASAN leak detection this * exercise reports a ~240-byte direct leak attributed to * makeInstance() (via makeJob_/makeJob, jobj.c) for THIS call. * Root-caused by inspection: makeInstance() allocates the object's * meta_obj_t+privdata+data block BEFORE invoking .init(), and when * .init() (job_init) returns a negative rc, makeInstance() returns * NULL to the caller without freeing that block -- every RTTI * .init() failure path in this codebase (including the pre-existing * "src->submit == NULL" branch a few lines above ours, and * jobsub_init's own ENOMEM branches) shares this exact * characteristic; it is a property of the vendored rtti library's * makeInstance(), not of job_init()'s logic, and not something * src/lib/jobj.c can fix (no source access, no in-boundary seam). * job_init() itself is correct here: Requirement 10.1 only asks it * to check makeJobsub()'s return and propagate failure instead of * storing NULL-with-success, which it now does -- this residual * leak would occur identically even if job_init() never allocated * anything at all before failing. Flagged to the parent task as a * separate, pre-existing, out-of-scope follow-up candidate. */ } /* ------------------------------------------------------------------- */ /* Requirement 10.3 -- MOVE_PTR fields are always NULL on a freshly */ /* built (calloc'd) submission payload, so reuse against a shared */ /* template across array elements is provably benign. */ /* ------------------------------------------------------------------- */ /* Mirrors build_job_submit_payloads() (src/controller/job.c) exactly: * calloc a fresh xce_job_t and set only submit/jobid/sub_time/ * queue_time/state -- every MOVE_PTR field (pidinfo/allocs/accounts/ * run_usage/exit_info/pend_reasons) is left at its calloc-zeroed NULL. * This is also the pattern the array-expansion path (restore_job_array, * a later task) will use per element. */ static void test_freshly_submitted_job_move_ptr_fields_are_null(void) { kvmgr_params_t jobmgr_params = { .key_type = KVMGR_KEY_INT, .nslots = 32, .delta = 32, /* job_t has .destroy = NULL (jobj.c) -- * teardown is wired only through its * reference interface (job_dereference). * The kvmgr's datakiller must therefore * be dereference(), not the generic * destroy(), or job_destroy() never * runs and every raw-heap MOVE_PTR * field (e.g. accounts) silently leaks * on manager teardown. */ .datakiller = dereference }; var jobmgr; job_submit_data_t submit1 = hard_make_submit("J-hard-10-3"); time_t now = time(NULL); xce_job_t* job_a = calloc(1, sizeof(*job_a)); xce_job_t* job_b = calloc(1, sizeof(*job_b)); job_t* j2 = NULL; job_t* j3 = NULL; TEST_ASSERT_NOT_NULL(job_a); TEST_ASSERT_NOT_NULL(job_b); hard_ensure_ready(); job_a->submit = &submit1; job_a->jobid = 90003; job_a->sub_time = now; job_a->queue_time = now; job_a->state = JOB_STAT_PEND; /* A second, independent element built from a different calloc'd * source but sharing the SAME submit template -- the array * construction pattern this feature relies on. */ job_b->submit = &submit1; job_b->jobid = 90004; job_b->sub_time = now; job_b->queue_time = now; job_b->state = JOB_STAT_PEND; jobmgr = makeInstance(&xkvmgr_type, &jobmgr_params); TEST_ASSERT_TRUE(jobmgr != NULL); j2 = makeJob(jobmgr, job_a); TEST_ASSERT_NOT_NULL(j2); TEST_ASSERT_NULL(j2->job.pidinfo); TEST_ASSERT_NULL(j2->job.allocs); TEST_ASSERT_NULL(j2->job.accounts); TEST_ASSERT_NULL(j2->job.run_usage); TEST_ASSERT_NULL(j2->job.exit_info); TEST_ASSERT_NULL(j2->job.pend_reasons); j3 = makeJob(jobmgr, job_b); TEST_ASSERT_NOT_NULL(j3); TEST_ASSERT_NULL(j3->job.pidinfo); TEST_ASSERT_NULL(j3->job.allocs); TEST_ASSERT_NULL(j3->job.accounts); TEST_ASSERT_NULL(j3->job.run_usage); TEST_ASSERT_NULL(j3->job.exit_info); TEST_ASSERT_NULL(j3->job.pend_reasons); /* makeJobsub()'s interning collapses the shared template to one * real allocation -- confirms the "right foundation for array * scale" design.md calls out. */ TEST_ASSERT_TRUE(j2->job.submit == j3->job.submit); destroy(jobmgr); free(job_a); free(job_b); } /* ------------------------------------------------------------------- */ /* Requirement 10.4 -- job_destroy() no longer leaks accounts. */ /* ------------------------------------------------------------------- */ static xce_job_account_t* hard_build_accounts(uint64_t jobid) { xce_job_account_t* acct = calloc(1, sizeof(*acct)); TEST_ASSERT_NOT_NULL(acct); acct->jobid = jobid; acct->nhosts = 1; acct->accounts = calloc(1, sizeof(*acct->accounts)); TEST_ASSERT_NOT_NULL(acct->accounts); xce_host_account_t* host = calloc(1, sizeof(*host)); TEST_ASSERT_NOT_NULL(host); /* host->host intentionally left NULL: free_host_account() (misc.c, * pre-existing, out of this task's src/lib/jobj.c boundary) does not * free that field -- a separate, unrelated gap in misc.c, not * Requirement 10.4's accounts leak this test targets. Leaving it * NULL keeps this test isolated to proving job_destroy() drives * free_job_account() correctly. */ host->naccount = 1; host->accounts = calloc(1, sizeof(*host->accounts)); TEST_ASSERT_NOT_NULL(host->accounts); xce_res_account_t* res = calloc(1, sizeof(*res)); TEST_ASSERT_NOT_NULL(res); res->resname = strdup("cpu"); res->used = 1.0; host->accounts[0] = res; acct->accounts[0] = host; acct->nres_shared = 1; acct->accounts_shared = calloc(1, sizeof(*acct->accounts_shared)); TEST_ASSERT_NOT_NULL(acct->accounts_shared); xce_res_account_t* shared = calloc(1, sizeof(*shared)); TEST_ASSERT_NOT_NULL(shared); shared->resname = strdup("mem"); acct->accounts_shared[0] = shared; return acct; } /* Run this test binary under ASAN with real leak detection * (ASAN_OPTIONS=detect_leaks=1, the xcelib_test default -- unlike the * controller suite, which sets detect_leaks=0 for pre-existing, unrelated * leaks) to prove job_destroy() no longer leaks j->job.accounts. */ static void test_job_destroy_does_not_leak_accounts(void) { kvmgr_params_t jobmgr_params = { .key_type = KVMGR_KEY_INT, .nslots = 32, .delta = 32, /* job_t has .destroy = NULL (jobj.c) -- * teardown is wired only through its * reference interface (job_dereference). * The kvmgr's datakiller must therefore * be dereference(), not the generic * destroy(), or job_destroy() never * runs and every raw-heap MOVE_PTR * field (e.g. accounts) silently leaks * on manager teardown. */ .datakiller = dereference }; var jobmgr; job_submit_data_t submit1 = hard_make_submit("J-hard-10-4"); time_t now = time(NULL); xce_job_t* job1 = calloc(1, sizeof(*job1)); job_t* j2 = NULL; TEST_ASSERT_NOT_NULL(job1); hard_ensure_ready(); job1->submit = &submit1; job1->jobid = 90005; job1->sub_time = now; job1->queue_time = now; job1->state = JOB_STAT_PEND; job1->accounts = hard_build_accounts(90005); jobmgr = makeInstance(&xkvmgr_type, &jobmgr_params); TEST_ASSERT_TRUE(jobmgr != NULL); j2 = makeJob(jobmgr, job1); TEST_ASSERT_NOT_NULL(j2); TEST_ASSERT_NOT_NULL(j2->job.accounts); /* MOVE_PTR'd in */ TEST_ASSERT_NULL(job1->accounts); /* moved out of src */ /* destroy(jobmgr) tears down every stored job_t via its datakiller * (dereference), which drives job_dereference -> job_destroy. */ destroy(jobmgr); free(job1); } /* ------------------------------------------------------------------- */ /* Requirement 10.5 -- decode_Job/free_Job have no ckptinfo wire field */ /* to leak in the first place. */ /* ------------------------------------------------------------------- */ /* Verified by inspection, not exercisable from src/lib: decode_Job() and * free_Job() are capnp-generated, controller-side symbols consuming * job.c, which src/lib does not link. struct Job's capnp schema * (src/schema/controller.capnp) declares jobid/submitData/subtime/ * queuetime/starttime/finishtime/state/allocs/runtime/pidinfo/ * pendreason/transientSince -- there is no ckptinfo field and no * "JobAccount"-style struct for accounts either. decode_Job() is * mechanically generated from that schema, so it structurally cannot * populate xce_job_t.ckptinfo; free_Job() correctly never frees it * either -- confirmed non-issue, not a gap. * * This test instead verifies the one piece of this invariant jobj.c * itself owns: job_init()'s ckptinfo handling for the realistic * freshly-submitted-job case (src->ckptinfo == NULL, exactly what * build_job_submit_payloads()/array-element construction produce) * leaves dst->job.ckptinfo NULL with no leak and no crash on destroy. */ static void test_ckptinfo_null_for_freshly_submitted_job_no_leak(void) { kvmgr_params_t jobmgr_params = { .key_type = KVMGR_KEY_INT, .nslots = 32, .delta = 32, /* job_t has .destroy = NULL (jobj.c) -- * teardown is wired only through its * reference interface (job_dereference). * The kvmgr's datakiller must therefore * be dereference(), not the generic * destroy(), or job_destroy() never * runs and every raw-heap MOVE_PTR * field (e.g. accounts) silently leaks * on manager teardown. */ .datakiller = dereference }; var jobmgr; job_submit_data_t submit1 = hard_make_submit("J-hard-10-5"); time_t now = time(NULL); /* ckptinfo intentionally left NULL by the initializer below -- * matches a freshly-submitted job. */ xce_job_t job1 = { .submit = &submit1, .jobid = 90006, .sub_time = now, .queue_time = now, .state = JOB_STAT_PEND }; job_t* j2 = NULL; hard_ensure_ready(); jobmgr = makeInstance(&xkvmgr_type, &jobmgr_params); TEST_ASSERT_TRUE(jobmgr != NULL); j2 = makeJob(jobmgr, &job1); TEST_ASSERT_NOT_NULL(j2); TEST_ASSERT_NULL(j2->job.ckptinfo); destroy(jobmgr); } void test_jobj_hardening(void) { printf("\n>> jobj hardening test (job-array-single-wal-record Requirement 10)\n\n"); RUN_TEST(test_job_obj_init_is_idempotent_without_fini); RUN_TEST(test_job_init_propagates_makeJobsub_failure); RUN_TEST(test_freshly_submitted_job_move_ptr_fields_are_null); RUN_TEST(test_job_destroy_does_not_leak_accounts); RUN_TEST(test_ckptinfo_null_for_freshly_submitted_job_no_leak); job_obj_fini(); xstring_table_fini(); g_hard_jobobj_ready = 0; g_hard_xstring_ready = 0; }