diff --git a/.kiro/specs/resreq-maxtasks-wiring/tasks.md b/.kiro/specs/resreq-maxtasks-wiring/tasks.md index bdc7537f..2a01a4b4 100644 --- a/.kiro/specs/resreq-maxtasks-wiring/tasks.md +++ b/.kiro/specs/resreq-maxtasks-wiring/tasks.md @@ -22,7 +22,7 @@ - _Requirements: 1.3_ - _Boundary: snapshot_load.c_ -- [ ] 2.3 (P) Close the worker's non-default launch-type coverage gap +- [x] 2.3 (P) Close the worker's non-default launch-type coverage gap - Write a white-box test proving the worker's per-host launch dispatch correctly defers to an MPI launch type's own remote-launch mechanism instead of fanning the job out itself - Write a white-box test proving the same dispatch correctly fans a default-launch-type job out across every allocated host itself - Write a white-box test proving the local-launch path's MPI command-line construction builds the correct host list and command, without requiring a real MPI runtime in the test environment diff --git a/src/worker/testsuite/meson.build b/src/worker/testsuite/meson.build index 2bda749f..e475d6b4 100644 --- a/src/worker/testsuite/meson.build +++ b/src/worker/testsuite/meson.build @@ -123,3 +123,27 @@ test('worker xcecd_resync test', xcecd_resync_test, suite: 'worker', timeout: 60, env: ['ASAN_OPTIONS=detect_leaks=0']) + +# resreq-maxtasks-wiring task 2.3 (Requirement 4.4): closes a pre-existing, +# zero-coverage gap in the worker's launch-type dispatch decision -- +# job_start_remote_task()'s MPI-vs-default launch-type gate and +# create_mpi_cmdline()'s command-line construction -- against unmodified +# production code (pure test addition, no production change). Same +# white-box #include of controller_connector.c and -DDBUS_MANAGER flag as +# controller_connector_test above; see test_worker_launch_type_dispatch.c's +# own header comment for why xcecli_runtask()/xcecli_unblock_tasks() cannot +# be stubbed here the way the other job-handling externs above are. +launch_type_dispatch_test = executable('launch_type_dispatch_test', + 'test_worker_launch_type_dispatch.c', + c_args: ['-DDBUS_MANAGER', + '-DXCE_CONTROLLER_CONNECTOR_C_PATH="' + + (meson.current_source_dir() / '..' / 'src' / 'controller_connector.c') + + '"'], + include_directories: [worker_test_incdir], + dependencies: worker_test_deps, + install: false) + +test('worker launch_type_dispatch test', launch_type_dispatch_test, + suite: 'worker', + timeout: 60, + env: ['ASAN_OPTIONS=detect_leaks=0']) diff --git a/src/worker/testsuite/test_worker_launch_type_dispatch.c b/src/worker/testsuite/test_worker_launch_type_dispatch.c new file mode 100644 index 00000000..1e1e287a --- /dev/null +++ b/src/worker/testsuite/test_worker_launch_type_dispatch.c @@ -0,0 +1,576 @@ +/* + * test_worker_launch_type_dispatch.c — white-box tests closing a pre- + * existing, zero-coverage gap in the worker's launch-type dispatch decision + * (resreq-maxtasks-wiring, task 2.3, boundary: WorkerLaunchTypeCoverage, + * Requirement 4.4). + * + * This gap is adjacent to, but not caused by, the rest of the resreq- + * maxtasks-wiring feature: Requirement 4.4 says "if a job's submission + * specifies a non-default launch type (for example, an MPI launch type), + * the Worker shall launch the job using that launch type's existing + * mechanism across all allocated hosts" -- and before this file, NOTHING + * exercised the two functions that decision actually depends on: + * + * - job_start_remote_task() (controller_connector.c): the launch-type + * GATE itself. For "openmpi"/"mpich" it logs and returns 0 immediately + * -- deferring entirely to MPI's own remote-launch mechanism (the + * command line create_mpi_cmdline() builds, run once via + * job_start_local_task(), covers every host itself). For any other + * launch type it builds an xce_taskhost_t[] covering + * launchinfo->hosts[1..nhosts-1] (host index 0 is the local host, + * started via job_start_local_task() instead) and calls + * xcecli_runtask() to fan the job out via XCE's own per-host dispatch. + * - create_mpi_cmdline() (controller_connector.c): the command-line + * construction job_start_local_task()'s MPI branch calls into -- + * builds the "-host"/"-hosts" argument from every allocated host's + * name:rank pair and wraps the original mpirun/mpiexec invocation with + * xce's own xrun launcher. + * + * This is a PURE TEST ADDITION against unmodified, existing production + * code (design.md's WorkerLaunchTypeCoverage component: "Does not modify + * controller_connector.c"). It follows test_controller_connector.c's + * established white-box pattern exactly: #include controller_connector.c + * directly (so these tests exercise the real static functions, not a + * reimplementation), link-satisfy the same set of externs that file's own + * job-handling code paths reference but this file's fixtures don't + * exercise, and use the same -DDBUS_MANAGER build flag as + * controller_connector_test so the #ifdef shape compiles identically to + * that already-established target. + * + * Verification technique for job_start_remote_task() (Req 4.4's real + * decision point): xcecli_runtask() and xcecli_unblock_tasks() are real + * functions from libxce's client.c, which this test binary genuinely + * links (controller_connector.c itself calls many other client.c + * functions -- xcecli_ctx_init, xcecli_join, xcecli_hello, etc. -- that + * test_controller_connector.c already exercises for real, so client.c.o is + * necessarily pulled whole out of libxce.a; a same-named stub definition + * in this file would collide with it at link time). A REAL, connected + * xcecli_ctx_t would route xcecli_runtask() through XCECLI_FAILOVER_CALL's + * full retry/backoff budget (up to ~100s on failure, per + * perform_pending_rejoin()'s own doc comment in controller_connector.c) -- + * impractical for a fast unit test, and design.md explicitly says none of + * this requires a real MPI/network runtime. Instead, these tests leave + * xcecli_ctx == NULL (its default): both xcecli_unblock_tasks() and + * xcecli_runtask() check for a NULL ctx and return immediately (-EINVAL + * for xcecli_runtask(), a plain no-op for xcecli_unblock_tasks()) BEFORE + * touching any lock or retry state -- fast, deterministic, and safe. Since + * -EINVAL is the ONLY value job_start_remote_task() can return via that + * path (its other returns are -1 for invalid parameters, -ENOMEM on a + * failed calloc, or 0 for the MPI no-op), seeing rc == -EINVAL back from + * job_start_remote_task() is unambiguous proof that xcecli_runtask() was + * actually reached -- and rc == 0 for an MPI launch type is unambiguous + * proof that it was not. + * + * The "excludes host 0" part of Req 4.4 (job_start_remote_task() fans the + * job out across every host the CONTROLLER allocated it to except index 0, + * which is separately started by job_start_local_task()) is proven with a + * deliberate landmine: launchinfo->hosts[0] is left NULL in the default- + * launch-type fixture below. job_start_remote_task()'s fan-out loop runs + * `for (i = 1; i < launchinfo->nhosts; i++)`; if a regression ever changed + * that lower bound to 0, the very first iteration would dereference + * launchinfo->hosts[0]->host on a NULL pointer and crash immediately -- + * there is no assertion that could paper over that, unlike a merely-wrong + * return code. + * + * create_mpi_cmdline() is fully self-contained (no xcecli_* / client.c + * dependency at all) and is called directly, so its returned command + * string is asserted verbatim. Per design.md ("without requiring a real + * MPI runtime"), the one external dependency it has -- an executable + * "xrun" launcher at $XCE_SERVER_DIR/xrun -- is satisfied with a trivial + * shell-script stub created once in main(), below; the tests never invoke + * it, they only need access(..., X_OK) to succeed against it. + */ +#include +#include +#include +#include +#include +#include +#include + +#include + +/* ── Fan-out content-verification mock (remediation) ───────────────────── + * + * job_start_remote_task()'s default-launch-type branch hands the built + * xce_taskrun_t to two src/lib/client.c entry points: xcecli_unblock_tasks() + * and xcecli_runtask(). Leaving xcecli_ctx == NULL (as the file header + * comment above explains) makes the REAL xcecli_runtask() short-circuit on + * its own NULL-ctx guard before ever reading the taskrun argument -- proving + * the call was reached, but nothing about what was actually built. + * + * A `#define`-before-`#include` macro substitution closes that gap: it + * rewrites the CALL SITE inside controller_connector.c to name the mocks + * below instead. This is a different technique from a same-named stub + * definition (which would collide at link time with the real + * xcecli_runtask()/xcecli_unblock_tasks() symbols this binary still pulls + * whole out of client.c.o for the other xcecli_* entry points + * controller_connector.c calls for real): a macro substitution never + * defines a duplicate global symbol, so there is no collision. It applies + * file-wide to this TU, so it also renames the unrelated xcecli_runtask() + * call inside the (unexercised by any test here) remote-job-start-by- + * hostname function further down in controller_connector.c -- harmless, + * since nothing calls that function in this file. + * + * client.h is included here, BEFORE the #define below, purely to bring + * xcecli_ctx_t/xce_taskrun_t/xce_taskhost_t into scope so the mocks can be + * declared with the exact real signatures, and so the mocks -- declared + * `static` -- are this TU's ONLY declaration of their own names (avoiding + * an extern-then-static linkage conflict). This first #include also + * establishes client.h's header guard, so controller_connector.c's own + * later `#include "client.h"` is a no-op that neither sees nor needs to see + * the #define below -- only the call sites inside controller_connector.c's + * function bodies (plain token occurrences of `xcecli_runtask`/ + * `xcecli_unblock_tasks`, substituted regardless of header guards) are + * what actually get redirected to the mocks. */ +#include "client.h" + +#define MOCK_RUNTASK_MAX_HOSTS 8 + +typedef struct { + int called; + uint64_t jobid; + int nhosts; + char host[MOCK_RUNTASK_MAX_HOSTS][128]; + int ntasks[MOCK_RUNTASK_MAX_HOSTS]; +} mock_runtask_capture_t; + +static mock_runtask_capture_t mock_runtask_capture; +static int mock_unblock_tasks_call_count; + +/* Signature mirrors the real xcecli_runtask() (src/include/client.h) + * exactly. Captures every field the strengthened fan-out test below + * asserts on (nhosts, jobid, and each taskhosts[i]'s host name and rank + * count), then returns success so job_start_remote_task() reports the + * fan-out succeeded. */ +static int mock_xcecli_runtask(xcecli_ctx_t ctx, xce_taskrun_t* taskrun) +{ + int i; + + (void)ctx; + + mock_runtask_capture.called = 1; + mock_runtask_capture.jobid = taskrun->jobid; + mock_runtask_capture.nhosts = taskrun->nhosts; + + for (i = 0; i < taskrun->nhosts && i < MOCK_RUNTASK_MAX_HOSTS; i++) { + xce_taskhost_t* th = taskrun->taskhosts[i]; + + if (th != NULL && th->host != NULL) { + strncpy(mock_runtask_capture.host[i], th->host, + sizeof(mock_runtask_capture.host[i]) - 1); + mock_runtask_capture.host[i][sizeof(mock_runtask_capture.host[i]) + - 1] = '\0'; + } else { + mock_runtask_capture.host[i][0] = '\0'; + } + mock_runtask_capture.ntasks[i] = (th != NULL) ? th->ntasks : -1; + } + + return 0; +} + +/* Signature mirrors the real xcecli_unblock_tasks() exactly; true no-op + * (matching the real NULL-ctx short-circuit's own observable behavior), so + * it cannot influence any assertion -- it only counts calls, for tests 1, + * 3, 4, 5 below to confirm (implicitly, by continuing to pass unmodified) + * that this mock does not change their behavior. */ +static void mock_xcecli_unblock_tasks(xcecli_ctx_t ctx) +{ + (void)ctx; + mock_unblock_tasks_call_count++; +} + +/* From here on, every plain-token occurrence of xcecli_runtask(...) / + * xcecli_unblock_tasks(...) -- including the ones inside + * controller_connector.c's own function bodies, included right below -- + * is redirected to the real-signature mocks just defined above. */ +#define xcecli_runtask(...) mock_xcecli_runtask(__VA_ARGS__) +#define xcecli_unblock_tasks(...) mock_xcecli_unblock_tasks(__VA_ARGS__) + +/* Pull the real launch-dispatch logic under test into this TU. */ +#include "../src/controller_connector.c" + +#ifndef XCE_CONTROLLER_CONNECTOR_C_PATH +#error \ + "XCE_CONTROLLER_CONNECTOR_C_PATH must be defined by meson.build to controller_connector.c's absolute path" +#endif + +/* controller_connector.c declares `extern hostattrs_t host_attrs;` in its + * non-XRUN branch (this build); provide the definition normally supplied by + * xcecd.c, which this test does not link -- same as + * test_controller_connector.c. */ +hostattrs_t host_attrs = { 0 }; + +/* ── Link-satisfying stubs for externs controller_connector.c references + * but this file's fixtures never exercise (thread pool / worker-list / + * limits-parsing / IPC-signal paths) -- identical set and rationale to + * test_controller_connector.c's own stub block; reused verbatim rather + * than reinvented, per this task's design constraint. ── */ +int submit_fork_task(thread_pool_t* pool, fork_task_t* task) +{ + (void)pool; + (void)task; + return 0; +} + +xcecd_worker_t* find_worker_by_jobid(xcecd_worker_t* worker_list, + uint64_t jobid) +{ + (void)worker_list; + (void)jobid; + return NULL; +} + +bool parse_limits(const char* exdata, res_limits_t* limits) +{ + (void)exdata; + (void)limits; + return true; +} + +bool ipc_master_send_job_signal(uint64_t job_id, int signal, bool force) +{ + (void)job_id; + (void)signal; + (void)force; + return true; +} + +bool ipc_master_send_job_ckpt(uint64_t job_id, int kill, const char* method) +{ + (void)job_id; + (void)kill; + (void)method; + return true; +} + +void xcecd_process_command(void* arg) { (void)arg; } + +void xcecd_finished_job_retention_set_window_ms(uint64_t window_ms) +{ + (void)window_ms; +} + +bool xcecd_resync_is_pending(void) { return false; } + +void xcecd_resync_reset(void) { } + +int xcecd_resync_handle_query_page(xcecd_worker_t* worker_list, endpoint_t* ep, + xce_peerinfo_t* from, + const xce_job_state_query_t* page, + uint64_t now_ms, int pktlimit) +{ + (void)worker_list; + (void)ep; + (void)from; + (void)page; + (void)now_ms; + (void)pktlimit; + return -EINVAL; +} + +/* ════════════════════════════════════════════════════════════════════════ + * Fixtures + * ════════════════════════════════════════════════════════════════════════ */ + +/* Directory/file created once in main() below (before UNITY_BEGIN()) so + * create_mpi_cmdline()'s access(launcher, X_OK) check succeeds without a + * real MPI/xrun install anywhere in the test environment. */ +#define FAKE_XRUN_DIR "/tmp/xce_test_worker_launch_type_dispatch_xrun" + +static void setup_fake_xrun_launcher(void) +{ + char path[512]; + + mkdir(FAKE_XRUN_DIR, 0755); /* ignore EEXIST from a prior run */ + snprintf(path, sizeof(path), "%s/xrun", FAKE_XRUN_DIR); + + FILE* f = fopen(path, "w"); + if (f) { + fputs("#!/bin/sh\nexit 0\n", f); + fclose(f); + } + chmod(path, 0755); + + setenv("XCE_SERVER_DIR", FAKE_XRUN_DIR, 1); +} + +void setUp(void) +{ + cc_ctx = NULL; + xcecli_ctx = NULL; + + memset(&mock_runtask_capture, 0, sizeof(mock_runtask_capture)); + mock_unblock_tasks_call_count = 0; +} + +void tearDown(void) { return; } + +/* ════════════════════════════════════════════════════════════════════════ + * Req 4.4 (primary target) — job_start_remote_task()'s launch-type gate: + * an MPI launch type must no-op, deferring entirely to MPI's own + * remote-launch mechanism (create_mpi_cmdline() + job_start_local_task(), + * exercised separately below) rather than fanning the job out itself. + * ════════════════════════════════════════════════════════════════════════ */ + +static void +test_job_start_remote_task_mpi_launch_types_defer_to_mpi_mechanism(void) +{ + static const char* mpi_types[] = { "openmpi", "mpich" }; + + for (size_t t = 0; t < sizeof(mpi_types) / sizeof(mpi_types[0]); t++) { + job_startinfo_t startinfo = { 0 }; + startinfo.launch_type = (char*)mpi_types[t]; + startinfo.exechome = (char*)"/home/user"; + startinfo.execcwd = (char*)"/home/user"; + startinfo.cmdline = (char*)"mpirun myapp"; + startinfo.execuser = (char*)"user1"; + + /* Landmine (see file header comment): if this no-op guard were + * ever removed or narrowed, execution would fall into the fan-out + * loop and immediately dereference launchinfo->hosts[1], which is + * deliberately NULL here -- an unmaskable crash, not just a wrong + * return value. */ + xce_host_launch_t host0 = { .host = (char*)"host0", .nranks = 2 }; + xce_host_launch_t* hosts[2] = { &host0, NULL }; + xce_launch_t launchinfo = { .nhosts = 2, .hosts = hosts }; + + xcecli_ctx = NULL; + + int rc = job_start_remote_task(1000 + (uint64_t)t, &startinfo, + &launchinfo); + + TEST_ASSERT_EQUAL_INT_MESSAGE( + 0, rc, + "an MPI launch type (\"openmpi\"/\"mpich\") must be a pure " + "no-op returning 0 -- deferring to MPI's own remote-launch " + "mechanism instead of fanning the job out via XCE's own " + "per-host dispatch"); + } + + printf( + "test_job_start_remote_task_mpi_launch_types_defer_to_mpi_" + "mechanism: PASS\n"); +} + +/* ════════════════════════════════════════════════════════════════════════ + * Req 4.4 (primary target) — job_start_remote_task()'s default-type branch: + * a non-MPI launch type must fan the job out via XCE's own per-host + * dispatch (xcecli_runtask()), covering every allocated host except index + * 0 (the local host, started separately by job_start_local_task()). + * ════════════════════════════════════════════════════════════════════════ */ + +static void +test_job_start_remote_task_default_launch_type_fans_out_excluding_host_zero( + void) +{ + job_startinfo_t startinfo = { 0 }; + startinfo.launch_type = (char*)"default"; + startinfo.exechome = (char*)"/home/user"; + startinfo.execcwd = (char*)"/home/user"; + startinfo.cmdline = (char*)"myapp arg1"; + startinfo.execuser = (char*)"user1"; + + xce_host_launch_t host1 = { .host = (char*)"remoteA", .nranks = 4 }; + xce_host_launch_t host2 = { .host = (char*)"remoteB", .nranks = 3 }; + /* hosts[0] is deliberately NULL: host index 0 is the local host, + * launched via job_start_local_task() instead, and must never be + * touched by this fan-out loop. If a regression ever changed the loop + * to start at i=0 instead of i=1, this crashes immediately on + * launchinfo->hosts[0]->host -- see file header comment. */ + xce_host_launch_t* hosts[3] = { NULL, &host1, &host2 }; + xce_launch_t launchinfo = { .nhosts = 3, .hosts = hosts }; + + /* xcecli_ctx's value is irrelevant now: xcecli_runtask()/ + * xcecli_unblock_tasks() are macro-substituted (see the mock block + * above the controller_connector.c #include) to test-local mocks that + * capture the real xce_taskrun_t contents instead of the real, + * NULL-ctx-short-circuiting client.c functions. Left NULL for + * consistency with the other tests in this file. */ + xcecli_ctx = NULL; + + int rc = job_start_remote_task(4242, &startinfo, &launchinfo); + + TEST_ASSERT_EQUAL_INT_MESSAGE( + 0, rc, + "with xcecli_runtask() mocked to succeed, job_start_remote_task() " + "must report success"); + TEST_ASSERT_TRUE_MESSAGE( + mock_runtask_capture.called, + "a non-MPI (default) launch type must fan the job out by actually " + "calling xcecli_runtask() -- not skip it the way the MPI launch " + "types above do"); + TEST_ASSERT_EQUAL_UINT64_MESSAGE( + 4242, mock_runtask_capture.jobid, + "the taskrun handed to xcecli_runtask() must carry the job's own " + "jobid"); + TEST_ASSERT_EQUAL_INT_MESSAGE( + 2, mock_runtask_capture.nhosts, + "the taskrun must report exactly nhosts-1 = 2 hosts -- every " + "allocated host except index 0 (the local host), neither more nor " + "fewer; this is the count a one-host-short fan-out regression " + "(e.g. stopping the host-filling loop at nhosts-1 instead of " + "nhosts) would get wrong"); + TEST_ASSERT_EQUAL_STRING_MESSAGE( + "remoteA", mock_runtask_capture.host[0], + "taskhosts[0] must be host index 1 from launchinfo (\"remoteA\") -- " + "the first host after the excluded local host 0"); + TEST_ASSERT_EQUAL_INT_MESSAGE( + 4, mock_runtask_capture.ntasks[0], + "taskhosts[0]'s rank count must match remoteA's allocated nranks"); + TEST_ASSERT_EQUAL_STRING_MESSAGE( + "remoteB", mock_runtask_capture.host[1], + "taskhosts[1] must be host index 2 from launchinfo (\"remoteB\") -- " + "proving the fan-out reaches every remaining allocated host, not " + "just the first one after the exclusion"); + TEST_ASSERT_EQUAL_INT_MESSAGE( + 3, mock_runtask_capture.ntasks[1], + "taskhosts[1]'s rank count must match remoteB's allocated nranks"); + + printf( + "test_job_start_remote_task_default_launch_type_fans_out_excluding_" + "host_zero: PASS\n"); +} + +/* ════════════════════════════════════════════════════════════════════════ + * Req 4.4 (secondary target) — create_mpi_cmdline(): the local-launch + * path's (job_start_local_task()) MPI command-line construction must build + * the correct host list and command, for each supported MPI type. + * ════════════════════════════════════════════════════════════════════════ */ + +static void +test_create_mpi_cmdline_openmpi_builds_correct_hostlist_and_command(void) +{ + xce_host_launch_t hostA = { .host = (char*)"hostA", .nranks = 4 }; + xce_host_launch_t hostB = { .host = (char*)"hostB", .nranks = 2 }; + xce_host_launch_t* hosts[2] = { &hostA, &hostB }; + xce_launch_t launchinfo = { .nhosts = 2, .hosts = hosts }; + + char cmd[] = "mpirun myapp arg1 arg2"; + char type[] = "openmpi"; + + char* result = create_mpi_cmdline(cmd, type, &launchinfo); + + TEST_ASSERT_NOT_NULL_MESSAGE( + result, "create_mpi_cmdline must succeed given a valid mpirun " + "command, a supported MPI type, and a populated host " + "list"); + TEST_ASSERT_EQUAL_INT_MESSAGE( + 0, strncmp(result, "mpirun ", strlen("mpirun ")), + "the final command must start with the original mpi executable"); + TEST_ASSERT_NOT_NULL_MESSAGE( + strstr(result, "-host hostA:4,hostB:2"), + "the -host option must list every allocated host with its rank " + "count, comma-separated, in order"); + TEST_ASSERT_NOT_NULL_MESSAGE( + strstr(result, "--mca plm_rsh_agent"), + "openmpi must use its own launcher-agent flag"); + TEST_ASSERT_NOT_NULL_MESSAGE( + strstr(result, "/xrun"), + "the xce xrun launcher path must be embedded in the final " + "command"); + size_t applen = strlen("myapp arg1 arg2"); + TEST_ASSERT_TRUE_MESSAGE( + strlen(result) >= applen + && strcmp(result + strlen(result) - applen, "myapp arg1 arg2") + == 0, + "the original application command must be preserved verbatim at " + "the end of the final command line"); + + printf( + "test_create_mpi_cmdline_openmpi_builds_correct_hostlist_and_" + "command: PASS (\"%s\")\n", + result); + FREEUP(result); +} + +static void +test_create_mpi_cmdline_mpich_builds_correct_hostlist_and_command(void) +{ + xce_host_launch_t hostA = { .host = (char*)"hostA", .nranks = 1 }; + xce_host_launch_t hostB = { .host = (char*)"hostB", .nranks = 1 }; + xce_host_launch_t hostC = { .host = (char*)"hostC", .nranks = 2 }; + xce_host_launch_t* hosts[3] = { &hostA, &hostB, &hostC }; + xce_launch_t launchinfo = { .nhosts = 3, .hosts = hosts }; + + char cmd[] = "mpiexec myjob --flag"; + char type[] = "mpich"; + + char* result = create_mpi_cmdline(cmd, type, &launchinfo); + + TEST_ASSERT_NOT_NULL_MESSAGE( + result, "create_mpi_cmdline must succeed given a valid mpiexec " + "command, a supported MPI type, and a populated host " + "list"); + TEST_ASSERT_EQUAL_INT_MESSAGE( + 0, strncmp(result, "mpiexec ", strlen("mpiexec ")), + "the final command must start with the original mpi executable"); + TEST_ASSERT_NOT_NULL_MESSAGE( + strstr(result, "-hosts hostA:1,hostB:1,hostC:2"), + "mpich's -hosts option must list every allocated host with its " + "rank count, comma-separated, in order (all three, not just two)"); + TEST_ASSERT_NOT_NULL_MESSAGE( + strstr(result, "-launcher-exec"), + "mpich must use its own launcher-exec flag, distinct from " + "openmpi's --mca plm_rsh_agent"); + size_t applen = strlen("myjob --flag"); + TEST_ASSERT_TRUE_MESSAGE( + strlen(result) >= applen + && strcmp(result + strlen(result) - applen, "myjob --flag") == 0, + "the original application command must be preserved verbatim at " + "the end of the final command line"); + + printf( + "test_create_mpi_cmdline_mpich_builds_correct_hostlist_and_command: " + "PASS (\"%s\")\n", + result); + FREEUP(result); +} + +/* Bonus robustness coverage, same target function: an MPI type outside the + * two supported values must be rejected, not silently accepted -- proves + * the type check itself is reached (the launcher-access check above it + * already passed, via the fake xrun stub, so a NULL here can only come + * from the unsupported-type branch). */ +static void test_create_mpi_cmdline_rejects_unsupported_mpi_type(void) +{ + xce_host_launch_t hostA = { .host = (char*)"hostA", .nranks = 1 }; + xce_host_launch_t* hosts[1] = { &hostA }; + xce_launch_t launchinfo = { .nhosts = 1, .hosts = hosts }; + + char cmd[] = "mpirun myapp"; + char type[] = "some-other-mpi"; + + char* result = create_mpi_cmdline(cmd, type, &launchinfo); + + TEST_ASSERT_NULL_MESSAGE( + result, + "an MPI type other than openmpi/mpich must be rejected (NULL), " + "not silently accepted"); + + printf("test_create_mpi_cmdline_rejects_unsupported_mpi_type: PASS\n"); +} + +/* ════════════════════════════════════════════════════════════════════════ + * main + * ════════════════════════════════════════════════════════════════════════ */ + +int main(void) +{ + xce_init_xcecd_log("/tmp/test_worker_launch_type_dispatch.log", + 1024 * 1024, 2); + setup_fake_xrun_launcher(); + + UNITY_BEGIN(); + + RUN_TEST( + test_job_start_remote_task_mpi_launch_types_defer_to_mpi_mechanism); + RUN_TEST( + test_job_start_remote_task_default_launch_type_fans_out_excluding_host_zero); + RUN_TEST(test_create_mpi_cmdline_openmpi_builds_correct_hostlist_and_command); + RUN_TEST(test_create_mpi_cmdline_mpich_builds_correct_hostlist_and_command); + RUN_TEST(test_create_mpi_cmdline_rejects_unsupported_mpi_type); + + return UNITY_END(); +}