diff --git a/distro/Dockerfile.bootable b/distro/Dockerfile.bootable index 30e536593..52547f1d8 100644 --- a/distro/Dockerfile.bootable +++ b/distro/Dockerfile.bootable @@ -80,6 +80,19 @@ RUN CC=gcc CFLAGS="-fPIC -O2 -ffreestanding -fno-builtin -fno-stack-protector -m "/tmp/build/link-native/SYSLIB/DECC\$SHR.EXE" \ "/tmp/build/link-native/SYSLIB/LIBVMSRMS\$SHR.EXE" +# RC3.EXE (vms-707): a minimal RUN target for the DCL $STATUS-propagation gate -- +# prints one line to SYS$OUTPUT and terminates with a nonzero completion status. +# Same cc -> LINK.EXE toolchain + producer images as PARTS.EXE above, so it +# activates through IMGACT.EXE over the ACP exactly as any RUN target does. +# tests/qemu/test_run_status_e2e.sh boots the runtime, RUNs it, and asserts DCL's +# $STATUS is the image's executive-recorded completion status (not a blanket +# %X00000001) AND that the image's stdout reached the console. +RUN CC=gcc CFLAGS="-fPIC -O2 -ffreestanding -fno-builtin -fno-stack-protector -mtls-dialect=gnu2" \ + sh src/apps/rctest/mk_rc3.sh \ + /tmp/build/bin/LINK.EXE \ + /tmp/build/link-native/RC3.EXE \ + "/tmp/build/link-native/SYSLIB/DECC\$SHR.EXE" + # Ground-source gate, baked into the build itself (not just asserted by a # separate CI job elsewhere): every artifact this stage ships must be a real # LINK.EXE-built x86_64 image with NO DT_NEEDED/DT_HASH (LINK.EXE emits no @@ -405,6 +418,7 @@ COPY --from=link-native /tmp/build/link-native/SYSLIB /link-native/SYSLIB COPY --from=link-native /tmp/build/link-native/SYSEXE /link-native/SYSEXE COPY --from=link-native /src/src/imgact/IMGACT.EXE /link-native/IMGACT.EXE COPY --from=link-native /tmp/build/link-native/PARTS.EXE /link-native/PARTS.EXE +COPY --from=link-native /tmp/build/link-native/RC3.EXE /link-native/RC3.EXE # KVER is the from-source kernel's kernelrelease (copied from kernel-build as # /tmp/kver above, e.g. 6.18.44-ovmx). The build tree lives at @@ -521,6 +535,7 @@ RUN mkdir -p /system-stage/dev /system-stage/proc /system-stage/sys \ # static+dynamic initramfs, DCL as the flagship dynamic proof. cp build-static/bin/STARTUP.EXE /system-stage/init && \ cp /link-native/IMGACT.EXE /system-stage/vms/SYS0/SYSCOMMON/SYSEXE/ && \ + cp /link-native/RC3.EXE /system-stage/vms/SYS0/SYSCOMMON/SYSEXE/ && \ cp "/link-native/SYSEXE/DCL.EXE" /system-stage/vms/SYS0/SYSCOMMON/SYSEXE/ && \ cp "/link-native/SYSEXE/LOGINOUT.EXE" /system-stage/vms/SYS0/SYSCOMMON/SYSEXE/ && \ cp /link-native/SYSLIB/*.EXE /system-stage/vms/SYS0/SYSCOMMON/SYSLIB/ && \ diff --git a/src/apps/rctest/mk_rc3.sh b/src/apps/rctest/mk_rc3.sh new file mode 100755 index 000000000..60ed37e43 --- /dev/null +++ b/src/apps/rctest/mk_rc3.sh @@ -0,0 +1,25 @@ +#!/bin/sh +# mk_rc3.sh - build RC3.EXE, a minimal RUN target for the DCL $STATUS gate +# (vms-707). Same toolchain as mk_parts.sh: cc compiles rc3.c, then LINK.EXE +# links it into a VMS-native ET_DYN executable (PT_INTERP=IMGACT.EXE, NO +# ld/ld.so, NO DT_NEEDED/DT_HASH) whose libc externals bind to DECC$SHR at +# activation. Usage: mk_rc3.sh [src-dir] +set -e +LINK_EXE=${1:?usage: mk_rc3.sh [src-dir]} +OUT=${2:?need output RC3.EXE path} +DECC_SHR=${3:?need DECC\$SHR.EXE} +HERE=$(cd "$(dirname "$0")" && pwd) +SRC=${4:-$HERE} +CC=${CC:-gcc} +[ -f "$DECC_SHR" ] || { echo "mk_rc3: producer image not found: $DECC_SHR"; exit 1; } +[ -f "$SRC/rc3.c" ] || { echo "mk_rc3: rc3.c not found in $SRC"; exit 1; } +WORK=${WORK:-/tmp/mk-rc3} +mkdir -p "$WORK" +CFLAGS="${CFLAGS:--fPIC -O2 -ffreestanding -fno-builtin -fno-stack-protector -mno-outline-atomics}" +DEFS="-D_POSIX_C_SOURCE=200809L -D_DEFAULT_SOURCE" +echo "mk_rc3: cc rc3.c" +# shellcheck disable=SC2086 +$CC $CFLAGS $DEFS -x c -c -o "$WORK/rc3.o" "$SRC/rc3.c" +echo "mk_rc3: LINK.EXE --executable --use DECC\$SHR -> $OUT" +"$LINK_EXE" --executable --use "$DECC_SHR" -o "$OUT" "$WORK/rc3.o" +echo "mk_rc3: created $OUT" diff --git a/src/apps/rctest/rc3.c b/src/apps/rctest/rc3.c new file mode 100644 index 000000000..5ac4a4f9f --- /dev/null +++ b/src/apps/rctest/rc3.c @@ -0,0 +1,30 @@ +/* + * rc3.c - a minimal RUN target for the DCL RUN-path acceptance gate (vms-707). + * + * The image prints one line to SYS$OUTPUT and exits successfully. RUN's fork + * path was reworked (waitid(WNOWAIT) peek -> executive $STATUS readback by Linux + * pid -> reap); this image is the end-to-end smoke test on the real runtime that + * the reworked path still (a) activates the image, (b) routes its stdout to the + * console, and (c) reports the image's completion status -- SS$_NORMAL for a + * clean exit, read back through the new path, never a hang or a wrong status. + * + * The FAITHFUL-ENCODING half of vms-707 -- a bit<0>-set completion condition + * (C$_EXIT1 + (N-1)*8) surviving to DCL's $STATUS instead of collapsing to + * %X00000001 -- is exercised by the Alpha GCC-port images (crtl_rms), whose + * IMGACT VMS-standard activation records the condition and releases the channel + * before exit; x86_64 has no such activation path (imgact.c is a stub there), so + * that half is proven at the executive level by tests/qemu/test_kmod_exit.c + * (Part 5, real /dev/vms) and end-to-end by the Alpha crtl_rms re-run. + * + * Built with the same cc -> LINK.EXE toolchain as PARTS.EXE (VMS-native ET_DYN, + * PT_INTERP=IMGACT.EXE), so it activates through the real image activator over + * the ACP exactly as any RUN target does. + */ +#include + +int main(void) +{ + printf("RC3: image output reached SYS$OUTPUT (RUN routed the child stdout)\n"); + fflush(stdout); + return 0; +} diff --git a/src/kernel-core/vms_proctab.c b/src/kernel-core/vms_proctab.c index 222368524..f95065c70 100644 --- a/src/kernel-core/vms_proctab.c +++ b/src/kernel-core/vms_proctab.c @@ -439,6 +439,29 @@ static struct vms_proc *find_by_vms_pid(uint32_t vms_pid) return NULL; } +/* + * find_by_linux_pid - locate a process by the Linux pid (thread-group id) + * backing its PCB. Unlike find_by_vms_pid(), this is unambiguous even when + * several rows SHARE one VMS PID: an image DCL activated through the + * fork()+execve() fallback runs as a continuation of DCL's identity + * (VMS_IOCTL_REGISTER_CONTINUE) and so carries DCL's VMS PID, but its own + * distinct Linux pid. This is the lookup behind VMS_JPI_SEL_LINUX_PID, the + * primitive RUN uses to read that child's true completion $STATUS (vms-707). + * + * Caller must hold vms_proc_hash_lock. + */ +static struct vms_proc *find_by_linux_pid(uint32_t linux_pid) +{ + struct vms_proc *proc; + int bkt; + + exec_hash_for_each(vms_proc_hash, bkt, proc, hash_node) { + if ((uint32_t)proc->linux_pid == linux_pid) + return proc; + } + return NULL; +} + /* * vms_ioctl_hiber - $HIBER, executive-resident and AST-interruptible (vms-feb). * @@ -1122,6 +1145,13 @@ long vms_ioctl_getexit(struct vms_proc *proc, unsigned long arg) case VMS_JPI_SEL_PID: target = find_by_vms_pid(args.vms_pid); break; + case VMS_JPI_SEL_LINUX_PID: + /* By backing Linux pid (vms-707): the args' vms_pid field carries the + * Linux pid here. RUN reads its fork()+execve() child's true $STATUS + * this way -- the child shares DCL's VMS PID, so only the Linux pid + * names its PCB row unambiguously. */ + target = find_by_linux_pid(args.vms_pid); + break; default: exec_unlock(&vms_proc_hash_lock); args.status = SS__BADPARAM; diff --git a/src/kernel-netbsd/vms_proctab_nb.h b/src/kernel-netbsd/vms_proctab_nb.h index 752a7dec1..b14cd76e0 100644 --- a/src/kernel-netbsd/vms_proctab_nb.h +++ b/src/kernel-netbsd/vms_proctab_nb.h @@ -135,6 +135,7 @@ #define VMS_JPI_SEL_SELF 0 /* the calling process */ #define VMS_JPI_SEL_PID 1 /* by vms_pid */ #define VMS_JPI_SEL_PRCNAM 2 /* by prcnam, within the caller's UIC group */ +#define VMS_JPI_SEL_LINUX_PID 3 /* GETEXIT only: by backing Linux pid (vms-707) */ #define VMS_PI_V_CPUTIM 0x00000001u /* cputim is sourced */ #define VMS_PI_V_PAGEFLTS 0x00000002u /* pageflts is sourced */ diff --git a/src/kernel/vms_ioctl.h b/src/kernel/vms_ioctl.h index be2ecb65f..211640b78 100644 --- a/src/kernel/vms_ioctl.h +++ b/src/kernel/vms_ioctl.h @@ -1368,6 +1368,18 @@ struct vms_procinfo { #define VMS_JPI_SEL_SELF 0 /* the calling process */ #define VMS_JPI_SEL_PID 1 /* by vms_pid */ #define VMS_JPI_SEL_PRCNAM 2 /* by prcnam, within the caller's UIC group */ +/* + * VMS_JPI_SEL_LINUX_PID (GETEXIT only, vms-707): read the completion $STATUS of + * a process named by its backing Linux pid, carried in the getexit args' + * `vms_pid` field. This is the primitive DCL's RUN uses to recover the true + * condition value of an image it activated through the fork()+execve() fallback: + * that child shares DCL's VMS PID (VMS_IOCTL_REGISTER_CONTINUE), so a by-VMS-PID + * read is ambiguous between DCL and the child, but the child's Linux pid -- which + * DCL holds from fork() -- names its PCB row uniquely. Read before the child is + * reaped (waitpid); an authorized read gated by vms_proc_may_read() exactly like + * SEL_PID. GETJPI does NOT accept this selector (its switch rejects it). + */ +#define VMS_JPI_SEL_LINUX_PID 3 struct vms_getjpi_args { uint32_t select; /* VMS_JPI_SEL_* */ diff --git a/src/libvmssys/kif_transport_linux.c b/src/libvmssys/kif_transport_linux.c index b4f077069..c8d19aef6 100644 --- a/src/libvmssys/kif_transport_linux.c +++ b/src/libvmssys/kif_transport_linux.c @@ -21,9 +21,25 @@ int kif_xport_dev_open(void) { - /* AT_FDCWD (-100), O_RDWR (2). The device name is the transport's own - * knowledge, per the contract -- the policy layer never spells it. */ - return vms_sys_openat(-100 /* AT_FDCWD */, "/dev/vms", 2 /* O_RDWR */, 0); + /* AT_FDCWD (-100), O_RDWR (2) | O_CLOEXEC (02000000 == 0x80000). The device + * name is the transport's own knowledge, per the contract -- the policy + * layer never spells it. + * + * O_CLOEXEC (vms-707): a /dev/vms channel must NOT survive execve(). The + * executive PCB is keyed on the thread group, not the channel, and every + * task re-binds its own channel on its first kif call after an execve + * (kif_bind / REGISTER_CONTINUE), so an inherited descriptor is never read. + * Worse, it is actively harmful: DCL's RUN fork child opens a channel via + * REGISTER_CONTINUE and then execve()s the image; without O_CLOEXEC that + * channel LEAKS into the image (no userspace handle references it), stays + * open across the image's whole run, and its implicit close at the image's + * do_exit() is what triggers vms_dev_release() to FREE the process's PCB -- + * destroying the image's recorded completion $STATUS before DCL can read it + * back. Closing the channel at execve() leaves the PCB owned solely by the + * image's own channel (which IMGACT closes before SYS$EXIT, not at exit), so + * the PCB survives to lazy reap and RUN can read the recorded $STATUS. */ + return vms_sys_openat(VMS_AT_FDCWD, "/dev/vms", + VMS_O_RDWR | VMS_O_CLOEXEC, 0); } void kif_xport_dev_close(int fd) diff --git a/src/libvmssys/vms_kif.c b/src/libvmssys/vms_kif.c index 6642f8f39..a29f839b1 100644 --- a/src/libvmssys/vms_kif.c +++ b/src/libvmssys/vms_kif.c @@ -1211,6 +1211,34 @@ uint32_t vms_kif_getexit(uint32_t *condition, int *has_exited) return args.status; } +/* Read the image-completion $STATUS of a process named by its backing Linux pid + * (vms-707). This is how DCL's RUN recovers the true condition value of an image + * it activated through the fork()+execve() fallback: the child shares DCL's VMS + * PID (REGISTER_CONTINUE), so a by-VMS-PID read is ambiguous, but the child's + * Linux pid -- which DCL holds from fork() -- names its PCB uniquely. Must be + * called BEFORE the child is reaped (waitpid), or the row is gone. *has_exited + * (if given) is nonzero iff an image actually recorded a status -- a foreign + * tool that never calls $EXIT leaves it 0, and the caller then derives $STATUS + * from the POSIX exit as before. INV-6: with no /dev/vms this returns the + * transport status and records nothing. */ +uint32_t vms_kif_getexit_linux(uint32_t linux_pid, uint32_t *condition, + int *has_exited) +{ + struct vms_getexit_args args; + + vms_memset(&args, 0, sizeof(args)); + args.select = VMS_JPI_SEL_LINUX_PID; + args.vms_pid = linux_pid; /* field carries the Linux pid here */ + + KIF_CALL(VMS_IOCTL_GETEXIT, &args); + + if (condition) + *condition = args.condition; + if (has_exited) + *has_exited = (int)args.has_exited; + return args.status; +} + /* Record this (CLI) process's invoking command line + cliflag in the executive, * so an image it activates reads the SAME context back (inherited from this * PCB at REGISTER_CONTINUE time). cliflag == 0 means "no CLI" and the command diff --git a/src/libvmssys/vms_kif.h b/src/libvmssys/vms_kif.h index 485bb8a08..a0be53e34 100644 --- a/src/libvmssys/vms_kif.h +++ b/src/libvmssys/vms_kif.h @@ -478,6 +478,16 @@ uint32_t vms_kif_setexit(uint32_t condition, uint32_t *exit_code); * zero *condition, which is a legal value). */ uint32_t vms_kif_getexit(uint32_t *condition, int *has_exited); +/* Read the image-completion $STATUS of a process named by its backing Linux pid + * (vms-707). DCL's RUN uses this to recover the true condition value of an image + * it activated through the fork()+execve() fallback -- that child shares DCL's + * VMS PID, so only the child's Linux pid names its PCB unambiguously. Must be + * called BEFORE the child is reaped. *has_exited (optional) is nonzero iff an + * image actually recorded a status (a foreign tool that never $EXITs leaves it + * 0). */ +uint32_t vms_kif_getexit_linux(uint32_t linux_pid, uint32_t *condition, + int *has_exited); + /* Record this CLI process's invoking command line + cliflag, for an activated * image to inherit and read back. cliflag == 0 means "no CLI". */ uint32_t vms_kif_setcli(uint32_t cliflag, const char *command); diff --git a/src/vmsdcl/dcl_cmd_process.c b/src/vmsdcl/dcl_cmd_process.c index 70aaf3604..81ad46b0e 100644 --- a/src/vmsdcl/dcl_cmd_process.c +++ b/src/vmsdcl/dcl_cmd_process.c @@ -1902,33 +1902,86 @@ static int dcl_activate_image_inner(struct dcl_context *ctx, execv(linux_path, argv); _exit(1); } else if (pid > 0) { - /* Parent - wait for child (WUNTRACED for Ctrl-Y stop support) */ + /* + * Parent. On OpenVMS the image runs IN the CLI's process and its + * completion condition value IS $STATUS. OVMX fork()s+execve()s, so the + * image records its VMS condition value in the executive PCB of this + * child (vms_kif_setexit, driven by IMGACT / sys_imgact at SYS$EXIT) and + * DCL reads it back here -- the fork fallback's authentic $STATUS path, + * mirroring the in-process path above (vms-707). Collapsing the POSIX + * child exit to SS$_NORMAL/SS$_ABORT, as this did before, discarded the + * real condition value: an image whose main returned 3 reported + * %X00000001, not the faithful DEC C encoding the executive held. + * + * Peek at the child's terminal state with WNOWAIT so its executive PCB + * is STILL PRESENT when we read it: a plain waitpid() reaps the child, + * and vms_proc_reap_dead() (run at the head of GETEXIT) then drops the + * row before we can read it. WEXITED|WSTOPPED catches both a Ctrl-Y stop + * and a true exit, the two outcomes the old WUNTRACED wait distinguished. + */ extern volatile sig_atomic_t dcl_running_child; dcl_running_child = (sig_atomic_t)pid; - int wstatus; - waitpid(pid, &wstatus, WUNTRACED); - dcl_running_child = 0; - if (WIFSTOPPED(wstatus)) { - /* Child was stopped by Ctrl-Y — save for CONTINUE */ + + siginfo_t si; + memset(&si, 0, sizeof(si)); + while (waitid(P_PID, (id_t)pid, &si, WEXITED | WSTOPPED | WNOWAIT) < 0 && + errno == EINTR) + ; + + if (si.si_code == CLD_STOPPED || si.si_code == CLD_TRAPPED) { + /* Child stopped by Ctrl-Y — save for CONTINUE (do NOT reap it). */ + dcl_running_child = 0; printf("\nInterrupt\n"); ctx->interrupted_pid = pid; return SS$_ABORT; } + + /* + * The child has exited but is not yet reaped (WNOWAIT), so its zombie + * task keeps the executive PCB alive. Read the image's recorded + * completion $STATUS by the child's Linux pid BEFORE reaping -- by-Linux- + * pid because the child SHARES DCL's VMS PID (REGISTER_CONTINUE), which + * makes a by-VMS-PID read ambiguous between DCL and the child. `recorded` + * is set iff an image actually recorded a status: a genuine OVMX image + * (IMGACT routes SYS$EXIT through the executive $EXIT) does; a foreign + * tool or a shebang script that never calls $EXIT does not. + */ + uint32_t cond = 0; + int recorded = 0; + uint32_t gx = vms_kif_getexit_linux((uint32_t)pid, &cond, &recorded); + + /* Now actually reap the child. */ + int wstatus = 0; + while (waitpid(pid, &wstatus, 0) < 0 && errno == EINTR) + ; + dcl_running_child = 0; + + if (gx == SS$_NORMAL && recorded) { + /* The executive holds the image's real VMS condition value -- that + * is $STATUS. Surface an error severity exactly as the in-process + * path does, then hand back the true condition value (not a + * POSIX-derived collapse). */ + if (!(cond & 1)) + dcl_error("DCL", (int)(cond & 7), "ABORT", + "image %s exited with error status %%X%08X", + display_name, (unsigned)cond); + return cond; + } + + /* + * No executive-recorded status (no /dev/vms, or a foreign tool that + * never called $EXIT): derive $STATUS from the POSIX outcome, exactly as + * before. vms-17f9: a nonzero exit or a killing signal is SURFACED, not + * swallowed, so a failed RUN never looks identical to a successful one. + */ if (WIFEXITED(wstatus)) { int exit_code = WEXITSTATUS(wstatus); - /* vms-17f9: a nonzero image exit must be SURFACED, not swallowed. - * Previously this returned SS$_ABORT silently, so a RUN that - * failed looked identical to one that succeeded (the de-risk that - * hunted a "no output" RUN, docs/derisk-vms-530-imgact-qemu.md). */ if (exit_code != 0) dcl_error("DCL", 2, "ABORT", "image %s exited with error status %%X%08X", display_name, (unsigned)exit_code); return (exit_code == 0) ? SS$_NORMAL : SS$_ABORT; } - /* vms-17f9: a child killed by a signal (a crash) was silently dropped - * here and cmd_run fell through to SS$_NORMAL, reporting success for an - * image that never ran. Report it and fail. */ if (WIFSIGNALED(wstatus)) { dcl_error("DCL", 4, "ABORT", "image %s terminated abnormally (signal %d)", diff --git a/src/vmslink/libvmssys_shr.vec b/src/vmslink/libvmssys_shr.vec index ab9d70fb6..eeaf6a7b4 100644 --- a/src/vmslink/libvmssys_shr.vec +++ b/src/vmslink/libvmssys_shr.vec @@ -177,3 +177,9 @@ vms_kif_bg_accept=PROCEDURE # executive cross-node RECEIVE seam reached over /dev/vms. APPENDED per the # append-only contract -- a new universal never shifts an existing index. vms_kif_dlm_xnode=PROCEDURE +# RUN's $STATUS readback for the fork()+execve() image-activation fallback +# (vms-707): read a forked child's executive-recorded completion $STATUS by its +# backing Linux pid, before waitpid reaps it, so DCL's $STATUS is the image's +# real VMS condition value -- not a success/fail collapse of the POSIX exit. +# APPENDED per the append-only contract -- a new universal never shifts an index. +vms_kif_getexit_linux=PROCEDURE diff --git a/src/vmslink/mk_decc_shr.sh b/src/vmslink/mk_decc_shr.sh index 48ff0dd5b..9f0778d07 100755 --- a/src/vmslink/mk_decc_shr.sh +++ b/src/vmslink/mk_decc_shr.sh @@ -877,6 +877,15 @@ if [ -f "$DECC_MAP" ]; then echo "mk_decc_shr: decc\$ CRTL alias vector: exported $n_add, skipped $n_skip (musl-undefined — gap for a later rung, not faked)" fi +# waitid (vms-707) APPENDED at the very end of the vector -> no prior universal's +# index moves (GSMATCH LEQUAL-compatible). DCL's RUN reads an activated image's +# executive-recorded completion $STATUS with waitid(P_PID, ..., WNOWAIT): a peek +# that does NOT reap the exited image, so its executive PCB survives to be read +# back before waitpid() reaps it. A bare musl universal DCL references directly +# (musl defines waitid); the curated bare-name block above is index-frozen, so it +# is appended here rather than inserted next to waitpid. +VEC="$VEC,waitid=PROCEDURE" + # Whole-archive, strict (NO --allow-undefined): a complete C-RTL shareable must # link with zero deferred externals. libc.a first so its strong defs win; the # loader-glue object last (it only REFERENCES __libc, which libc.a defines). diff --git a/tests/qemu/CMakeLists.txt b/tests/qemu/CMakeLists.txt index d807c749e..2775a6620 100644 --- a/tests/qemu/CMakeLists.txt +++ b/tests/qemu/CMakeLists.txt @@ -895,6 +895,21 @@ set_tests_properties(dcl_acceptance_e2e PROPERTIES LABELS "slow;qemu-full-boot" SKIP_RETURN_CODE 77 TIMEOUT 1200) +# --------------------------------------------------------------------------- +# run_status_e2e (vms-707): boot the real runtime, log in, RUN SYS$SYSTEM:RC3.EXE, +# and assert the reworked DCL RUN fork path (waitid(WNOWAIT) peek -> executive +# $STATUS readback by Linux pid -> reap) routes the activated image's stdout to +# the console AND reports its completion status (SS$_NORMAL for RC3's clean exit) +# through the new readback path -- never a hang, spurious error, or wrong status. +# Same opt-in convention (OVMX_QEMU_FULL_E2E=1) and shape as dcl_acceptance_e2e. +add_test( + NAME run_status_e2e + COMMAND bash ${CMAKE_CURRENT_SOURCE_DIR}/run_run_status_e2e.sh +) +set_tests_properties(run_status_e2e PROPERTIES + LABELS "slow;qemu-full-boot" + SKIP_RETURN_CODE 77 + TIMEOUT 1200) # console_boot_no_newline_spam_e2e (vms-dec): the coverage every other boot # harness lacks. All the gates above wait for "Username:" and only THEN feed diff --git a/tests/qemu/run_run_status_e2e.sh b/tests/qemu/run_run_status_e2e.sh new file mode 100755 index 000000000..30eb59f2f --- /dev/null +++ b/tests/qemu/run_run_status_e2e.sh @@ -0,0 +1,58 @@ +#!/bin/bash +# run_run_status_e2e.sh - ctest entry point for run_status_e2e (vms-707). +# +# THE GATE ITSELF (tests/qemu/test_run_status_e2e.sh) boots the REAL runtime (the +# pre-mastered ODS-2 distribution disk baked into the ovmx-boot image), logs in +# SYSTEM/MANAGER, RUNs SYS$SYSTEM:RC3.EXE, and asserts the reworked DCL RUN fork +# path (waitid(WNOWAIT) peek -> executive $STATUS readback by Linux pid -> reap) +# still, on the real runtime: (1) routes the activated image's stdout to the +# console, and (2) reports the image's completion status (SS$_NORMAL for RC3's +# clean exit) through the new readback path -- never a hang, spurious error, or +# wrong/absent status. +# +# It needs a real docker + QEMU boot (real minutes), so -- exactly like +# run_dcl_acceptance_e2e.sh -- it is registered with ctest for discoverability +# but gated behind OVMX_QEMU_FULL_E2E=1 so a bare `ctest` run never pays for a +# boot it cannot use. CI treats a SKIP (77) on the release-cut path as a hard +# failure so it can never silently no-op. +# +# Env knobs: +# OVMX_QEMU_FULL_E2E must be "1" or this script SKIPs (exit 77). +# OVMX_BOOT_IMAGE image tag to run (default: ovmx-boot). +# OVMX_BUILD_BOOT_IMAGE "1" to build OVMX_BOOT_IMAGE if not present (default 0). +# BOOT_TIMEOUT / CMD_TIMEOUT forwarded to the gate itself. + +set -uo pipefail + +SKIP=77 +REPO_ROOT=$(cd "$(dirname "$0")/../.." && pwd) +IMAGE="${OVMX_BOOT_IMAGE:-ovmx-boot}" + +if [ "${OVMX_QEMU_FULL_E2E:-0}" != "1" ]; then + echo "SKIP: run_status_e2e requires OVMX_QEMU_FULL_E2E=1 (real docker+QEMU boot, real minutes)." + echo " Run directly: OVMX_QEMU_FULL_E2E=1 OVMX_BOOT_IMAGE=$IMAGE tests/qemu/run_run_status_e2e.sh" + exit "$SKIP" +fi + +command -v docker >/dev/null 2>&1 || { echo "SKIP: docker not available"; exit "$SKIP"; } + +if ! docker image inspect "$IMAGE" >/dev/null 2>&1; then + if [ "${OVMX_BUILD_BOOT_IMAGE:-0}" = "1" ]; then + echo "--- building bootable image ($IMAGE) ---" + docker build -t "$IMAGE" -f "$REPO_ROOT/distro/Dockerfile.bootable" "$REPO_ROOT" \ + || { echo "FAIL: bootable image build"; exit 1; } + else + echo "FATAL: image '$IMAGE' not found. Either:" + echo " docker build -t $IMAGE -f distro/Dockerfile.bootable ." + echo "or re-run with OVMX_BUILD_BOOT_IMAGE=1 to build it here." + exit 1 + fi +fi + +exec docker run --rm \ + -e "BOOT_TIMEOUT=${BOOT_TIMEOUT:-180}" \ + -e "CMD_TIMEOUT=${CMD_TIMEOUT:-30}" \ + -v "$REPO_ROOT/tests/qemu/test_run_status_e2e.sh:/test.sh:ro" \ + --entrypoint bash \ + "$IMAGE" \ + /test.sh diff --git a/tests/qemu/test_kmod_exit.c b/tests/qemu/test_kmod_exit.c index f25b183ca..505ed05f1 100644 --- a/tests/qemu/test_kmod_exit.c +++ b/tests/qemu/test_kmod_exit.c @@ -66,6 +66,13 @@ #define COND_WARN 0u /* severity 0 (WARNING), bit<0> clear */ #define COND_INFO 0x0000012Bu /* bit<0> set, severity 3 (INFO): 0x12B & 7 == 3 */ #define COND_CHILD 0x00038090u /* cross-process vector: bit<0> clear, sev 0 */ +/* + * The faithful DEC C encoding of `main` returning 3: C$_EXIT1 + (3-1)*8 == + * 0x0035A019 (vms-707, the JOINT_E2E $STATUS the executive recorded while DCL's + * RUN fork path collapsed it to %X00000001). Used below to prove RUN's readback + * primitive round-trips the EXACT condition value, not a POSIX success/fail. + */ +#define COND_RUN3 0x0035A019u /* return 3: bit<0> clear (even = failure sev) */ #define STS_SUCCESS(c) ((unsigned)((c) & 0x1u)) #define STS_SEVERITY(c) ((unsigned)((c) & 0x7u)) @@ -115,6 +122,33 @@ static int do_getexit(int fd, uint32_t select, uint32_t vms_pid, return 0; } +/* GETEXIT for a process named by its backing Linux pid (VMS_JPI_SEL_LINUX_PID, + * vms-707). The Linux pid travels in the args' vms_pid field. This is the exact + * primitive DCL's RUN fork path uses to read a forked image's true $STATUS. */ +static int do_getexit_linux(int fd, uint32_t linux_pid, + struct vms_getexit_args *out) +{ + struct vms_getexit_args a; + memset(&a, 0, sizeof(a)); + a.select = VMS_JPI_SEL_LINUX_PID; + a.vms_pid = linux_pid; /* field carries the Linux pid for this selector */ + if (ioctl(fd, VMS_IOCTL_GETEXIT, &a) != 0) + return -1; + *out = a; + return 0; +} + +/* Read the calling process's own VMS PID via $GETJPI(self). Returns 0 on error. */ +static uint32_t do_getjpi_self_pid(int fd) +{ + struct vms_getjpi_args a; + memset(&a, 0, sizeof(a)); + a.select = VMS_JPI_SEL_SELF; + if (ioctl(fd, VMS_IOCTL_GETJPI, &a) != 0 || a.status != SS_NORMAL) + return 0; + return a.info.vms_pid; +} + /* One $EXIT/$STATUS self round-trip: record `cond`, read it back, and assert * the recorded longword and its decoded success/severity/exit_code. */ static void check_status_roundtrip(int fd, uint32_t cond, const char *label) @@ -347,6 +381,90 @@ int main(void) } } + /* --- Part 5: RUN fork-path $STATUS readback by Linux pid (vms-707) --- + * + * This is the exact mechanism DCL's RUN now uses. On OpenVMS the image runs + * IN the CLI's process and its completion condition value IS $STATUS; OVMX + * fork()s+execve()s, so the image (a REGISTER_CONTINUE continuation of DCL's + * identity) records its condition value in ITS OWN PCB, and DCL reads it + * back. Because the child SHARES DCL's VMS PID, a by-VMS-PID read is + * ambiguous between the two rows -- so RUN reads by the child's Linux pid, + * which names its row uniquely. Prove both facts against a real /dev/vms: + * the child's VMS PID EQUALS the parent's (ambiguity is real), and the + * by-Linux-pid read returns the EXACT condition value the child recorded. */ + printf("--- RUN fork-path $STATUS readback (SEL_LINUX_PID) ---\n"); + { + int c2r[2]; + if (pipe(c2r) != 0) { + CHECK(0, "pipe() for RUN fork-path exit test"); + } else { + pid_t kid = fork(); + if (kid == 0) { + /* CHILD: the activated image. Continue the CLI's identity (so it + * shares the CLI's VMS PID), record a completion condition value, + * report its VMS PID, then EXIT -- exactly as a real fork()ed + * image does. The parent must read the recorded status in the + * zombie window (WNOWAIT), before it reaps, which is precisely + * what dcl_activate_image now does. */ + close(c2r[0]); + int cfd = open("/dev/vms", O_RDWR); + struct child_exit_report rep; memset(&rep, 0, sizeof rep); + if (cfd >= 0 && do_register(cfd, 1) != 0) { + struct vms_exit_args se; + if (do_setexit(cfd, COND_RUN3, &se) == 0 && + se.status == SS_NORMAL) { + rep.vms_pid = do_getjpi_self_pid(cfd); + rep.ok = 1; + } + } + (void)!write(c2r[1], &rep, sizeof rep); + if (cfd >= 0) close(cfd); + _exit(0); /* becomes a zombie until reaped */ + } + /* PARENT: the CLI (DCL). It knows the child's Linux pid from fork(). + * Read the report, then reproduce DCL's exact sequence: peek with + * WNOWAIT so the exited child's PCB is still present, read its + * $STATUS by Linux pid, THEN reap. */ + close(c2r[1]); + struct child_exit_report rep; memset(&rep, 0, sizeof rep); + ssize_t n = read(c2r[0], &rep, sizeof rep); + close(c2r[0]); + CHECK(n == (ssize_t)sizeof rep && rep.ok, + "activated image REGISTER_CONTINUEd and recorded its $STATUS"); + CHECK(n == (ssize_t)sizeof rep && rep.ok && rep.vms_pid == self_pid, + "the child SHARES the CLI's VMS PID (by-VMS-PID read is ambiguous)"); + + /* Peek without reaping -- the zombie keeps the PCB alive. */ + siginfo_t si; memset(&si, 0, sizeof si); + while (waitid(P_PID, (id_t)kid, &si, WEXITED | WNOWAIT) < 0 && + errno == EINTR) + ; + + if (n == (ssize_t)sizeof rep && rep.ok) { + struct vms_getexit_args ge; + int rc = do_getexit_linux(fd, (uint32_t)kid, &ge); + CHECK(rc == 0 && ge.status == SS_NORMAL, + "RUN reads the exited child's $STATUS by its Linux pid " + "(zombie window, before reap)"); + CHECK(rc == 0 && ge.condition == COND_RUN3, + "RUN reads back the EXACT recorded condition value " + "(0x0035A019, NOT a POSIX-collapsed 0x1)"); + CHECK(rc == 0 && ge.has_exited == 1, + "RUN sees has_exited == 1 for the activated image"); + CHECK(rc == 0 && ge.success == STS_SUCCESS(COND_RUN3) && + ge.severity == STS_SEVERITY(COND_RUN3), + "RUN decodes the image's success/severity correctly"); + /* A Linux pid no process backs is SS$_NONEXPR, not a fabricated + * success -- the honest "no such process" answer. */ + struct vms_getexit_args gz; + int rz = do_getexit_linux(fd, 0x7fffffffu, &gz); + CHECK(rz == 0 && gz.status != SS_NORMAL && gz.has_exited == 0, + "an unbacked Linux pid reads SS$_NONEXPR, not a fake status"); + } + waitpid(kid, NULL, 0); /* now reap */ + } + } + close(fd); printf("=== test_kmod_exit: %d passed, %d failed ===\n", pass, fail); return fail == 0 ? 0 : 1; diff --git a/tests/qemu/test_run_status_e2e.sh b/tests/qemu/test_run_status_e2e.sh new file mode 100755 index 000000000..3b8187578 --- /dev/null +++ b/tests/qemu/test_run_status_e2e.sh @@ -0,0 +1,162 @@ +#!/bin/sh +# test_run_status_e2e.sh - DCL RUN-path acceptance gate (vms-707). +# +# Boots the REAL runtime (the pre-mastered ODS-2 distribution disk baked into the +# ovmx-boot image), logs in SYSTEM/MANAGER, and RUNs SYS$SYSTEM:RC3.EXE -- an +# image that prints one line to SYS$OUTPUT and exits successfully. +# +# The RUN fork path was reworked for vms-707 (waitid(WNOWAIT) peek -> executive +# $STATUS readback by Linux pid -> reap). This is the end-to-end smoke test that +# the reworked path still, on the real runtime: +# 1. OUTPUT ROUTING: routes the activated image's stdout to the console. +# 2. $STATUS: reports the image's completion status through the new readback +# path -- SS$_NORMAL (%X00000001) for this clean exit -- never a hang or a +# wrong/absent status. +# +# The FAITHFUL-ENCODING half (a bit<0>-set condition C$_EXIT1+(N-1)*8 surviving +# to $STATUS instead of collapsing to %X00000001) is an Alpha GCC-port property +# (IMGACT VMS-standard activation, which x86_64 lacks -- imgact.c stub); it is +# proven at the executive level by tests/qemu/test_kmod_exit.c (Part 5, real +# /dev/vms) and end-to-end by the Alpha crtl_rms re-run this change unblocks. +# +# Run INSIDE the ovmx-boot image (distro/Dockerfile.bootable), which supplies +# qemu-system-* AND the pre-mastered disk with RC3.EXE in SYS$SYSTEM:. +# +# Exit 0 = RUN activates, routes output, and reports the right status. Exit 1 = +# the reworked RUN path regressed (see the printed transcript). + +set -uo pipefail + +BOOT_TIMEOUT="${BOOT_TIMEOUT:-180}" +CMD_TIMEOUT="${CMD_TIMEOUT:-30}" + +DISTRIB_IMG=/boot/ovmx-distrib.img +KERNEL=/boot/vmlinuz +SLIM_INITRD=/boot/initramfs-ovmx-slim.cpio.gz +ARCH=$(uname -m) + +if [ "$ARCH" = "aarch64" ] || [ "$ARCH" = "arm64" ]; then + QEMU=qemu-system-aarch64 + MACHINE="-machine virt -cpu cortex-a57" + CONSOLE="console=ttyAMA0" +else + QEMU=qemu-system-x86_64 + MACHINE="" + CONSOLE="console=ttyS0" +fi + +for f in "$KERNEL" "$SLIM_INITRD" "$DISTRIB_IMG"; do + [ -f "$f" ] || { echo "FATAL: $f not found - run this INSIDE the ovmx-boot image"; exit 1; } +done +command -v "$QEMU" >/dev/null 2>&1 || { echo "FATAL: $QEMU not available"; exit 1; } + +PASS=0 +FAIL=0 +ok() { echo " PASS: $1"; PASS=$((PASS + 1)); } +bad() { echo " FAIL: $1"; FAIL=$((FAIL + 1)); } + +echo "=== OVMX DCL RUN \$STATUS-propagation e2e (vms-707): boot, login, RUN RC3.EXE, SHOW SYMBOL \$STATUS ===" +echo "arch=$ARCH qemu=$QEMU" + +DISK=/tmp/run-status-e2e.img +LOG=/tmp/run-status-e2e-console.log +FIFO=/tmp/run-status-e2e-console.in +rm -f "$DISK" "$LOG" "$FIFO" +cp "$DISTRIB_IMG" "$DISK" +mkfifo "$FIFO" + +WALL=$((BOOT_TIMEOUT + CMD_TIMEOUT * 6 + 120)) +cleanup() { exec 4>&- 2>/dev/null || true; [ -n "${QPID:-}" ] && kill "$QPID" 2>/dev/null; rm -f "$FIFO"; } +trap cleanup EXIT + +# shellcheck disable=SC2086 +timeout "$WALL" $QEMU $MACHINE \ + -kernel "$KERNEL" -initrd "$SLIM_INITRD" \ + -nographic -append "$CONSOLE loglevel=3 quiet" \ + -m 512M -smp 2 -nic none -nodefaults -serial stdio \ + -drive file="$DISK",format=raw,if=virtio,cache=writethrough \ + -no-reboot <"$FIFO" >"$LOG" 2>&1 & +QPID=$! +exec 4>"$FIFO" + +send() { printf '%s\r' "$1" >&4; } +wait_for() { # pattern limit-seconds since-byte + local pat="$1" limit="${2:-30}" since="${3:-0}" waited=0 + while [ "$waited" -lt "$((limit * 4))" ]; do + if tail -c "+$((since + 1))" "$LOG" 2>/dev/null | grep -qaF -- "$pat"; then return 0; fi + kill -0 "$QPID" 2>/dev/null || return 1 + sleep 0.25; waited=$((waited + 1)) + done + return 1 +} +segment_since() { tail -c "+$(($1 + 1))" "$LOG" 2>/dev/null | tr -d '\r'; } +dump_and_die() { + echo ""; echo "=== FATAL: $1 ==="; echo "--- full console log ---"; cat "$LOG" + kill "$QPID" 2>/dev/null; wait "$QPID" 2>/dev/null; exit 1 +} +SEG="" +run_cmd() { + local cmd="$1" off + off=$(wc -c <"$LOG") + send "$cmd" + wait_for '$ ' "$CMD_TIMEOUT" "$off" + sleep 1 + SEG=$(segment_since "$off") +} + +# --- Boot to login ---------------------------------------------------------- +if wait_for '%OVMX-I-EXEC' 60; then ok "executive attached (real vms.ko)"; else bad "executive never attached"; fi +w=0 +until grep -qaF 'Username:' "$LOG" 2>/dev/null || [ "$w" -ge "$BOOT_TIMEOUT" ]; do + send ''; sleep 1; w=$((w + 1)) +done +wait_for 'Username:' 5 || dump_and_die "boot never reached Username: within ${BOOT_TIMEOUT}s" +ok "runtime boots to the login prompt" + +LOGIN_OFF=$(wc -c <"$LOG") +send 'SYSTEM' +wait_for 'Password:' 30 "$LOGIN_OFF" && send 'MANAGER' +wait_for 'Welcome to OpenVMX' 30 "$LOGIN_OFF" || dump_and_die "SYSTEM login failed" +ok "SYSTEM logs in (LOGINOUT.EXE -> DCL.EXE off the mounted ODS-2 disk)" +wait_for '$ ' 20 "$LOGIN_OFF" + +# --- RUN the image and read its $STATUS ------------------------------------- +run_cmd 'RUN SYS$SYSTEM:RC3.EXE' +RUN_SEG="$SEG" +echo "----- verbatim: RUN SYS\$SYSTEM:RC3.EXE -----" +printf '%s\n' "$RUN_SEG" +echo "--------------------------------------------" + +# 1. OUTPUT ROUTING: the activated image's stdout reached the console. +if printf '%s\n' "$RUN_SEG" | grep -qF 'RC3: image output reached SYS$OUTPUT'; then + ok "RUN routes the activated image's stdout to the console (vms-707 output half)" +else + bad "RUN did NOT route the image's stdout to the console (the RC3 line is absent)" +fi + +run_cmd 'SHOW SYMBOL $STATUS' +STATUS_SEG="$SEG" +echo "----- verbatim: SHOW SYMBOL \$STATUS -----" +printf '%s\n' "$STATUS_SEG" +echo "------------------------------------------" + +# 2. $STATUS: the reworked readback path reports the image's completion status. +# RC3 exits cleanly, so the faithful status is SS$_NORMAL (%X00000001) -- read +# back through the new waitid-peek/getexit path, proving it runs end-to-end on +# the real runtime and reports the right value (never a hang or wrong status). +if printf '%s\n' "$STATUS_SEG" | grep -qiE '\$STATUS[^0-9A-Fa-f]*"?%X00000001'; then + ok "SHOW SYMBOL \$STATUS reports SS\$_NORMAL (%X00000001) for the clean exit, via the reworked readback path" +else + bad "SHOW SYMBOL \$STATUS is not SS\$_NORMAL for a successful RUN -- the reworked RUN path regressed" +fi +# The RUN must not have reported an error for an image that exited cleanly. +if printf '%s\n' "$RUN_SEG" | grep -qiE '%DCL-.-ABORT|error status'; then + bad "RUN reported an error for RC3.EXE which exited cleanly (spurious failure)" +else + ok "RUN reported no error for the clean exit (no spurious %DCL-*-ABORT)" +fi + +echo "" +echo "=== RUN \$STATUS e2e: $PASS passed, $FAIL failed ===" +kill "$QPID" 2>/dev/null; wait "$QPID" 2>/dev/null +[ "$FAIL" -eq 0 ] && exit 0 || exit 1