diff --git a/src/vmsscs/include/scs_member.h b/src/vmsscs/include/scs_member.h index b2a6c263..52decb46 100644 --- a/src/vmsscs/include/scs_member.h +++ b/src/vmsscs/include/scs_member.h @@ -338,6 +338,15 @@ int scs_member_build_dlm_response(const struct scs_member_params *p, int scs_member_build_dlm_selfreg(const struct scs_member_params *p, uint8_t out[SCS_MEMBER_FRAME_LEN]); +/* db20-b (vms-7e2): originate an honest NULL-mode DLM registration (cat 0x02 op + * 0x01 ENQ, mode NL) for a discovered resource, toward a non-coordinator member. + * INV-6 by construction: NO mode parameter -- the mode byte is hard-pinned to NL + * (0x00), so a held mode can never be emitted. See scs_member.c. */ +int scs_member_build_dlm_nl_enq(const struct scs_member_params *p, + uint16_t dir_hash, + const char *resname, uint8_t namelen, + uint8_t out[SCS_MEMBER_FRAME_LEN]); + /* Current time as a VMS 64-bit absolute time (100 ns since 17-NOV-1858). */ uint64_t scs_member_vms_time_now(void); diff --git a/src/vmsscs/scs_member.c b/src/vmsscs/scs_member.c index add86d31..2a2427c3 100644 --- a/src/vmsscs/scs_member.c +++ b/src/vmsscs/scs_member.c @@ -752,6 +752,63 @@ int scs_member_build_dlm_selfreg(const struct scs_member_params *p, return 0; } +/* + * scs_member_build_dlm_nl_enq - originate an honest NULL-mode (NL) DLM + * registration (category 0x02, op 0x01 ENQ, mode NL) for a resource OVMX has been + * SHOWN, toward a non-coordinator member's DLM VC (db20-b, vms-7e2). + * + * INV-6 GUARANTEE, ENFORCED BY CONSTRUCTION: the lock mode (body[30]) is + * HARD-PINNED to 0x00 (NL / null lock). This function has NO mode parameter and + * can NEVER emit a held mode (CR/CW/PR/PW/EX) or an op-07 convert. NL asserts + * directory participation holding NOTHING -- INV-6-safe by construction (vms-199). + * The reference joiner sends 3152 HELD-mode ENQs to VAX1 because it genuinely + * holds those locks; OVMX holds none, so it registers ONLY NL participation in the + * resources the cluster has shown it (from the pushed op-0d rebuild records OVMX + * already echoes) -- it invents nothing and never claims its own held resources + * (it has none). + * + * Grounded from a VAX3->VAX1 NL op-01 ENQ in vax3-2to3 (resource VCC$vSYSDSK1). + * Only the resource NAME comes from the discovered record. member_count is OVMX's + * OWN true count (the caller sources it from ovmx_cluster.member_count, never from + * the peer's lock record -- which carries no member_count field). dir_hash is an + * honest ZERO: a SCS$DIRECTORY dir-hash is computed from the resource name, not + * echoed from a peer's ENQ, and OVMX does not compute the VMS hash -- so it omits + * it (0) rather than invent one (INV-6). Every other per-message field is zero- + * filled; the value block is null. body[4:8] (per-VC tag + counter) and the SCS + * envelope are minted per send. + */ +int scs_member_build_dlm_nl_enq(const struct scs_member_params *p, + uint16_t dir_hash, + const char *resname, uint8_t namelen, + uint8_t out[SCS_MEMBER_FRAME_LEN]) +{ + if (p == NULL || out == NULL || resname == NULL) { + return -1; + } + if (namelen == 0 || namelen > 31) { /* a VMS resource name is 1..31 bytes */ + return -1; + } + build_common(p, member_config_tmpl, SCS_MEMBER_ENV_CREDIT_CONFIG, out); + + uint8_t *body = out + 72; + memset(body + 4, 0, SCS_MEMBER_SCA_LEN - SCS_MEMBER_BODY_OFF - 4); + put_le16(body + 0, p->sysap_send_msg); + put_le16(body + 2, p->sysap_ack_msg); + put_le16(body + 4, p->txn); /* per-VC directory tag (opaque, minted) */ + put_le16(body + 6, p->checksum); /* per-VC monotonic counter (opaque, minted) */ + body[8] = SCS_MEMBER_CAT_DLM; /* 0x02 */ + body[9] = 0x01; /* op 0x01 = ENQ */ + put_le16(body + 10, dir_hash); /* SCS$DIRECTORY dir-hash: honest 0 (computed from the name, not echoed; OVMX omits rather than invents -- INV-6) */ + body[12] = 0x01; /* node-independent constant */ + put_le16(body + 14, p->member_count); /* OVMX's own true post-transition member count */ + /* body[16:30] zero -- ungrounded per-message ids (INV-6: never replay VAX3's). */ + body[30] = 0x00; /* == NL. HARD-PINNED -- the INV-6 guarantee. */ + /* body[31:47] zero. */ + body[47] = namelen; /* resource-name length */ + memcpy(body + 48, resname, namelen); /* the discovered resource name; value block stays null */ + return 0; +} + int scs_member_build_response(const struct scs_member_params *p, const uint8_t *req_frame, size_t req_len, uint8_t out[SCS_MEMBER_FRAME_LEN]) diff --git a/src/vmsscs/scsd.c b/src/vmsscs/scsd.c index 0ea23421..1181b296 100644 --- a/src/vmsscs/scsd.c +++ b/src/vmsscs/scsd.c @@ -373,6 +373,8 @@ static void ovmx_cluster_logical(uint16_t sysid, uint8_t out[6]) /* --- vms-5fe: directed-HELLO / SCS-connect responder state --- */ #define OVMX_MAX_PEERS 4 +/* vms-7e2 (db20-b): cap on distinct resources NL-registered to one member. */ +#define OVMX_DLM_NL_MAX 48 /* vms-298 / vms-584 item 5: THE CON.ID HIGH WORD IS PER-BOOT, NOT COMPILE-TIME. * @@ -1049,6 +1051,10 @@ struct peer_state { * body[6:8] counter is ps->own_cksum (shared with the barrier steps); the * txn tag is the SCSD_DLM_DIR_TAG constant. */ int dlm_selfreg_sent; /* one-shot: self-reg emitted for the current epoch */ + /* vms-7e2 (db20-b): resources already NL-registered to this member (dedup -- + * register each shown cluster resource once, honest NL participation). */ + char dlm_nl_reg[OVMX_DLM_NL_MAX][32]; + int dlm_nl_reg_n; /* rd vms-ec75 (DLM rung H11): DISTRIBUTED DEADLOCK SEARCH. A contender node * holds one resource (mastered by C) and $ENQs a second that QUEUES behind the * other contender -- a genuine cross-node wait-for cycle. When the wait queues, @@ -3673,6 +3679,11 @@ static ssize_t send_frame_raw(int sock, int ifindex, const uint8_t mac[6], * between barrier steps 4 and 5 on the one CM send_seq * stream; a null-value-block directory record, CHOKED * like every other sequenced VC message + * cm_send_dlm_nl_register() the cat-0x02 op-0x01 NULL-mode DLM registration + * (vms-7e2/db20-b) OVMX originates to a non-coordinator + * member for each cluster resource it is shown -- honest + * NL directory participation (mode hard-pinned NL, holds + * nothing); CHOKED like every other sequenced VC message * scs_reflect_credit() the op8->op9 / op6->op7 credit handshake reply * scs_send_disconnect_self() self-directed teardown, hand-built frame * @@ -6477,6 +6488,113 @@ static int cm_send_dlm_selfreg(int sock, int ifindex, struct peer_state *ps, return 0; } +/* + * dlm_op0d_resname - extract the DLM lock resource name from a received op-0d + * rebuild record's SYSAP body (body = frame + 72). The name is ASCII at + * SCS_DLM_B_RESNAM (48) -- a VMS resource name of 1..31 bytes, space/NUL-padded + * (corroborated by the DLM field map in scs_dlm.h, scs_member.c's own "RESOURCE + * in ASCII at body[48:]" handler, and a live dump of VAX1's op-0d records: + * VCC$vSYSDSK1, F11B$bSYSDSK1, CACHE$cmSYSDSK1, SYS$_$2$DUA0). Writes a 32-byte + * pad-preserving copy to out[32] (stable dedup key) and returns the trimmed name + * length, or 0 if body[48] does not begin with A-Z (not a resource name). + * + * This is its own function so the offset is unit-testable: the db20-b origin bug + * read the name from body[20] (SCS_DLM_B_MASTER_CSID, a u32 -- never a letter), + * so the guard silently skipped every record and NO NL registration ever fired. + * A pure extractor turns that class of offset error into a failing assertion + * (test_scsd_wire) instead of a silent runtime no-op. + */ +static uint8_t dlm_op0d_resname(const uint8_t *body, char out[32]) +{ + memset(out, 0, 32); + memcpy(out, body + SCS_DLM_B_RESNAM, 31); + if (!(out[0] >= 'A' && out[0] <= 'Z')) { + return 0; + } + uint8_t nl = 31; + while (nl > 0 && (out[nl - 1] == ' ' || out[nl - 1] == '\0')) { + nl--; + } + return nl; +} + +/* + * dlm_nl_reg_add - record that we have NL-registered resource `res` (a 32-byte + * space/NUL-padded name field; a VMS resource name is 1..31 bytes) to this member; + * returns 1 if it is NEW (register it now), 0 if already registered or the per-member + * cap is reached (skip). Dedup so each shown resource is registered once (db20-b). + */ +static int dlm_nl_reg_add(struct peer_state *ps, const char res[32]) +{ + for (int i = 0; i < ps->dlm_nl_reg_n; i++) { + if (memcmp(ps->dlm_nl_reg[i], res, 32) == 0) { + return 0; + } + } + if (ps->dlm_nl_reg_n >= OVMX_DLM_NL_MAX) { + return 0; + } + memcpy(ps->dlm_nl_reg[ps->dlm_nl_reg_n++], res, 32); + return 1; +} + +/* + * cm_send_dlm_nl_register - originate an honest NULL-mode DLM registration (cat + * 0x02 op 0x01 ENQ, mode NL) for a resource a non-coordinator member has shown us + * (db20-b, vms-7e2). Modelled on cm_send_dlm_selfreg; the frame is built by + * scs_member_build_dlm_nl_enq, which HARD-PINS the mode to NL -- OVMX holds no + * locks, so it registers only directory participation (holding nothing), never a + * held-mode ENQ or an op-07 convert. The reference joiner runs this rebuild + * exchange with each non-coordinator member; VAX1's SCA$TRANSPORT probe (the + * member-set trigger) follows it draining. + */ +static int cm_send_dlm_nl_register(int sock, int ifindex, struct peer_state *ps, + const uint8_t our_hw_mac[6], + const uint8_t our_src_logical[6], + uint16_t dir_hash, uint16_t member_count, + const char *resname, uint8_t namelen) +{ + if (ps->cm_local_conid == 0) { + return 0; + } + struct scs_member_params bp; + memset(&bp, 0, sizeof(bp)); + memcpy(bp.dst_mac, ps_port_addr(ps), 6); + memcpy(bp.src_mac, our_hw_mac, 6); + memcpy(bp.src_logical, our_src_logical, 6); + memcpy(bp.peer_logical, ps_sys_addr(ps), 6); + bp.remote_conid = ps->cm_remote_conid; + bp.local_conid = ps->cm_local_conid; + bp.incarnation = ps->incarnation; + bp.recv_ack = ps->vc.seq.recv_seq; + bp.send_seq = scs_seq_advance(&ps->vc.seq); + if (ps->sysap_send == 0) { + ps->sysap_send = 1; + } + bp.sysap_send_msg = ps->sysap_send++; + bp.sysap_ack_msg = ps->sysap_recv; + bp.txn = SCSD_DLM_DIR_TAG; /* per-VC directory tree tag */ + bp.checksum = ++ps->own_cksum; /* per-VC monotonic counter, contiguous */ + bp.member_count = member_count; /* OVMX's OWN true membership count (caller + * passes ovmx_cluster.member_count) -- NOT + * read from the peer's lock record, which has + * no such field. INV-6: our fact, not theirs. */ + uint8_t dframe[SCS_MEMBER_FRAME_LEN]; + if (scs_member_build_dlm_nl_enq(&bp, dir_hash, resname, namelen, dframe) == 0 && + send_frame_vc(sock, ifindex, ps, ps->pb, + "CM DLM NL registration (cat 0x02 op 0x01, mode NL)", + dframe, sizeof(dframe)) > 0) { + scs_vc_record_sent(&ps->vc, bp.send_seq, monotonic_ms()); + log_ts(stdout); + printf(" SCSD-I-DLMNLREG, originated NL-mode DLM registration" + " (cat 0x02 op 0x01 NL, res='%.*s' hash=0x%04x cksum=0x%04x seq=%u)\n", + (int)namelen, resname, dir_hash, bp.checksum, bp.send_seq); + fflush(stdout); + return 1; + } + return 0; +} + /* * cm_send_ack - emit one category-0x04 SYSAP acknowledgement naming our current * high-water mark on the VC the connection-manager dialogue is riding. @@ -7927,6 +8045,49 @@ static void scsd_sysap_msg_input(struct scs_cdt *cdt, const void *msg, size_t ms cm_shape == CM_RSP_TOKEN ? "token-only" : (cm_shape == CM_RSP_DLM ? "dlm-echo" : "echoed"), mp.sysap_send_msg, mp.sysap_ack_msg); fflush(stdout); + + /* vms-7e2 (db20-b): having echoed a non-coordinator + * member's op-0d rebuild record, register honest NL + * participation in that resource -- the directory-rebuild + * exchange the member needs before it advances to the + * SCA$TRANSPORT member-STATUS probe. NL only (holding + * nothing); OVMX invents nothing -- the resource NAME comes + * from the record we were shown, and every other field is + * either OVMX's own true state or an honest zero (INV-6). */ + if (cm_shape == CM_RSP_DLM && + !cm_peer_is_coordinator(rx->peers, ps)) { + const uint8_t *qb = buf + 72; + /* Resource name is ASCII at body[48] (see + * dlm_op0d_resname); nl==0 means body[48] is not a + * plausible name -- skip. Dedup so each shown resource + * registers once. */ + char res[32]; + uint8_t nl = dlm_op0d_resname(qb, res); + if (nl > 0 && dlm_nl_reg_add(ps, res)) { + /* member_count: OVMX's OWN true membership count, + * NEVER the wire. The received op-0d is a LOCK + * record (scs_dlm.h: master_lkid@8:12, status@12:16, + * resnam@48:80) -- it carries NO member_count field, + * so any read from it would FABRICATE the count + * (INV-6 violation). member_count is a membership + * fact OVMX already holds (ovmx_cluster.member_count, + * the same source op-01 PARAMS uses). + * + * dir_hash: honest ZERO. A SCS$DIRECTORY dir-hash is + * COMPUTED from the resource name, not carried in a + * peer's lock ENQ; the op-0d record has no such field + * to echo, and OVMX does not (yet) compute the VMS + * hash. 0 is the ungrounded sentinel -- honest + * omission over an invented value (INV-6). */ + uint16_t dh = 0; + uint16_t mc = ovmx_cluster.known ? + ovmx_cluster.member_count : 0; + cm_send_dlm_nl_register(rx->sock, (int)rx->ifindex, + ps, rx->our_hw_mac, + rx->our_src_logical, + dh, mc, res, nl); + } + } } } } diff --git a/tests/vmsscs/test_scs_member.c b/tests/vmsscs/test_scs_member.c index 265bfee4..7a3de721 100644 --- a/tests/vmsscs/test_scs_member.c +++ b/tests/vmsscs/test_scs_member.c @@ -953,6 +953,42 @@ static void test_dlm_selfreg_null_guards(void) CHECK(scs_member_build_dlm_selfreg(&mp, NULL) == -1, "build_dlm_selfreg NULL out"); } +/* db20-b: the honest NL-mode DLM registration. The mode byte MUST be NL (0x00) -- + * the INV-6 guarantee -- and the frame is cat 0x02 op 0x01 ENQ carrying the + * discovered resource name; every ungrounded field is zero. */ +static void test_dlm_nl_enq_is_null_mode(void) +{ + struct scs_member_params mp; + joiner_params(&mp, 0, 0, 0x00d8, 0x00de); /* sysap send/ack */ + mp.member_count = 3; + mp.txn = 0x0004; + mp.checksum = 0x07f4; + + uint8_t out[SCS_MEMBER_FRAME_LEN]; + const char *res = "VCC$vSYSDSK1"; + CHECK(scs_member_build_dlm_nl_enq(&mp, 0x5041, res, (uint8_t)strlen(res), out) == 0, + "build_dlm_nl_enq ok"); + const uint8_t *body = out + 14 + SCS_MEMBER_BODY_OFF; /* SCA[58] = abs 72 */ + + CHECK(body[8] == 0x02, "cat 0x02 (DLM)"); + CHECK(body[9] == 0x01, "op 0x01 (ENQ)"); + CHECK(body[30] == 0x00, "body[30] == NL (0x00) -- INV-6 guarantee: never a held mode"); + CHECK(body[10] == 0x41 && body[11] == 0x50, "body[10:12] = per-resource directory hash"); + CHECK(body[14] == 0x03 && body[15] == 0x00, "body[14:16] = member count"); + CHECK(body[47] == (uint8_t)strlen(res), "body[47] = resource-name length"); + CHECK(memcmp(body + 48, res, strlen(res)) == 0, "body[48:] = discovered resource name"); + /* value block after the name is null. */ + int vlb_nz = 0; + for (size_t i = 48 + strlen(res); i < (size_t)(SCS_MEMBER_SCA_LEN - SCS_MEMBER_BODY_OFF); i++) { + if (body[i] != 0) { vlb_nz = 1; } + } + CHECK(!vlb_nz, "value block null (NL asserts no held value)"); + /* the builder has NO mode parameter, so a held mode is structurally un-emittable. */ + CHECK(scs_member_build_dlm_nl_enq(NULL, 0, res, 4, out) == -1, "nl_enq NULL p"); + CHECK(scs_member_build_dlm_nl_enq(&mp, 0, res, 0, out) == -1, "nl_enq rejects zero namelen"); + CHECK(scs_member_build_dlm_nl_enq(&mp, 0, res, 32, out) == -1, "nl_enq rejects oversized namelen"); +} + int main(void) { test_op14_byte_exact(); @@ -974,6 +1010,7 @@ int main(void) test_params_member_vs_joiner_form(); test_dlm_selfreg_byte_exact(); test_dlm_selfreg_null_guards(); + test_dlm_nl_enq_is_null_mode(); if (failures == 0) { printf("test_scs_member: ALL PASSED\n"); diff --git a/tests/vmsscs/test_scsd_wire.c b/tests/vmsscs/test_scsd_wire.c index 89cf988d..3ca1f6ec 100644 --- a/tests/vmsscs/test_scsd_wire.c +++ b/tests/vmsscs/test_scsd_wire.c @@ -10838,6 +10838,52 @@ static void test_dlm_message_reaches_the_lock_handler_over_scs(void) "the decoded value block did not match"); } +/* + * db20-b (vms-7e2): the op-0d resource-name extractor must read body[48] + * (SCS_DLM_B_RESNAM), NOT body[20]. The origin bug read body[20] -- which is + * SCS_DLM_B_MASTER_CSID, a u32 whose first byte is never A-Z -- so the guard + * silently skipped EVERY rebuild record and no NL registration ever fired + * (DLMNLREG=0 in the lab, even with the hook precondition met). This pins the + * offset so that class of bug fails a check instead of vanishing at runtime. + */ +static void test_dlm_op0d_resname_reads_body48(void) +{ + uint8_t body[132]; + memset(body, 0, sizeof(body)); + /* A CSID-shaped nonzero value at body[20] (byte 0 = '@', exactly what the + * live VAX1 op-0d dump showed there) -- the old, wrong read site. */ + body[20] = 0x40; body[21] = 0x00; body[22] = 0x11; body[23] = 0x00; + /* The real resource name at SCS_DLM_B_RESNAM (48), space-padded. */ + const char *name = "VCC$vSYSDSK1"; + memset(body + SCS_DLM_B_RESNAM, ' ', 31); + memcpy(body + SCS_DLM_B_RESNAM, name, strlen(name)); + + char out[32]; + uint8_t nl = dlm_op0d_resname(body, out); + CHECK(nl == (uint8_t)strlen(name), + "dlm_op0d_resname must return the trimmed name length %zu, got %u", + strlen(name), (unsigned)nl); + CHECK(memcmp(out, name, strlen(name)) == 0, + "dlm_op0d_resname must read the name from body[48], not body[20]"); + + /* An empty body[48] (whatever sits at body[20]) is rejected -> the guard + * fires nothing. This is the exact condition the origin bug hit for all 177 + * records: body[48] blank, so no false registration. */ + memset(body + SCS_DLM_B_RESNAM, 0, 31); + memcpy(body + 20, "GARBAGE", 7); + CHECK(dlm_op0d_resname(body, out) == 0, + "dlm_op0d_resname must ignore body[20]; only body[48] holds the name"); + + /* A full-width 31-byte name is preserved (no 16-byte truncation). */ + memset(body, 0, sizeof(body)); + const char *longname = "ABCDEFGHIJKLMNOPQRSTUVWXYZ01234"; /* 31 chars */ + memcpy(body + SCS_DLM_B_RESNAM, longname, 31); + nl = dlm_op0d_resname(body, out); + CHECK(nl == 31 && memcmp(out, longname, 31) == 0, + "dlm_op0d_resname must preserve a full 31-byte name, got len %u", + (unsigned)nl); +} + int main(void) { /* THE FAILURE STREAM, taken before anything can dup2() over fd 2. See @@ -11022,6 +11068,8 @@ int main(void) test_poll_refresh_tick_drives_the_daemon_loop(); /* vms-f61 (spec ยง4(O.21)): the readmission-map verdict classifier. */ test_readmit_verdict_classifies_the_rejoin_frontier(); + /* db20-b (vms-7e2): the op-0d resource-name offset -- body[48], not body[20]. */ + test_dlm_op0d_resname_reads_body48(); CHECK(peer_logical_offset > 0, "the peer-logical offset was never located -- the offset-dependent"