fix(mpe_assert_abort): anchor the cut on the whole escaped separator
Build / Build-and-ng-test (pull_request) Failing after 2m0s
Build / Build-and-test-development (pull_request) Skipped
Lighthouse Checks / lighthouse (pull_request) Successful in 16m59s

Cutting on the marker plus a newline still truncated a message that ends a line
with "Log Extract:", and an abort whose message merely prefixed the expected one
could pass. The appended extract always emits '\n\nLog Extract:\n', so the anchor
is that whole string (doubled form first, single as fallback): a message cannot
reproduce it by accident, and nothing is cut when neither form is present, so an
unexpected layout fails loudly instead of shortening the text.

The tail strip goes with it - the leading newlines are part of the anchor - and
the test for the doubled separator now writes the fully escaped form the estate
actually emits.

mpe_abortmessage cuts an over-long message to the room left after the prefix
instead of losing its tail to the 32767 limit, and mpe_execute's fallback skips
the trailing blank when the child's message is blank, matching the helper.
This commit is contained in:
dc committed 2026-10-31 02:41:54 +00:00
1 parent 24b8cbe9dc
commit e74a7ba115
4 files changed
+31 -42

No files matched your search

+13 -15
View File
@@ -27,28 +27,26 @@
%macro mpe_abortmessage(programvar=program,statevar=state,msgvar=msg);
data _null_;
/* explicit lengths: an inline symget() is typed by the data step default,
which truncates a long child message */
/* explicit lengths: symget() nested inside another function is typed by
the data step default of $200, so a long endpoint name or message is
truncated before the assignment whatever the target declares. Each value
is read by name for the same reason: interpolating program or state into
this step would let a %, & or quote in an endpoint name be macro-resolved
or break the literal. */
length _prog $32767 _state $32767 _prefix $32767 _msg $32767 _out $32767;
/* every value is read by name: interpolating program or state into this
data step would let a %, & or quote in an endpoint name be macro-resolved
or break the literal */
/* cat, not cats: cats strips the blanks around each argument, which would
close up the space before 'did' */
/* read each value into its own variable first: symget() nested inside
another function is typed by the data step default of $200, so a long
endpoint name is truncated before the assignment, whatever length the
target declares */
_prog=symget("&programvar");
_state=symget("&statevar");
_prefix=cat(trim(_prog),' did not complete: state=',trim(_state));
_msg=symget("&msgvar");
_prefix=cat(trim(_prog),' did not complete: state=',trim(_state));
/* plain concatenation, not CATX: CATX strips the blanks around each
argument, which would close up the space before the message. Internal
blanks are kept; leading and trailing ones are not, because symputx
strips them when the text becomes a macro variable */
/* trim the prefix: it is declared as a character variable, so it arrives
padded to length and the child's message would land past the buffer end */
strips them when the text becomes a macro variable. */
/* SAS character variables stop at 32767, so a message already at that
limit cannot be joined to the prefix: cut it to the room that is left, so
the text is deliberately short rather than silently losing its tail */
_room=32767-length(trim(_prefix))-2;
if length(_msg)>_room then _msg=substr(_msg,1,_room);
if missing(_msg) then _out=trim(_prefix);
else _out=trim(_prefix)||' '||_msg;
call symputx('_mpe_abortmsg',_out,'g');
+12 -24
View File
@@ -81,30 +81,18 @@ data _null_;
end;
end;
call symputx('abortseen',abortseen,'l');
/* mp_abort appends the extract as cats(msg,'\n\nLog Extract:\n',logmsg)
and then escapes the message, so the separator arrives as the marker
followed by an escaped newline. Cut at the FIRST separator - the marker
plus that newline. A message that merely mentions "Log Extract:" is not
followed by a newline, so it survives; and a log line that contains the
marker cannot move the cut past the separator and leave extract residue. */
/* the newline after the marker is escaped like every other backslash,
so it arrives doubled; the single form is the fallback for a response
assembled without that escape */
_marker=index(got,'Log Extract:'||'\\n');
if _marker=0 then _marker=index(got,'Log Extract:'||'\n');
if _marker>0 then do;
if _marker=1 then got='';
else got=substr(got,1,_marker-1);
/* mp_abort appends the extract as cats(msg,'\n\nLog Extract:\n',logmsg)
and then escapes the message, so the separator arrives as two escaped
newlines - six characters once the backslashes are doubled.
Remove exactly that tail: a message that ends in its own escaped newline
keeps it, which a loop over trailing escapes got wrong. */
if length(got)>=6 and substr(got,length(got)-5,6)='\\n\\n' then
got=substr(got,1,length(got)-6);
else if length(got)>=4 and substr(got,length(got)-3,4)='\n\n' then
got=substr(got,1,length(got)-4);
end;
/* mp_abort appends the extract as cats(msg,'\n\nLog Extract:\n',logmsg)
and then escapes every backslash, so the separator arrives fully doubled
- or single-escaped for a response assembled without that step. Anchor on
the whole separator rather than on the marker plus a newline: a message
that ends a line with "Log Extract:" cannot then be truncated, and a log
line that contains the marker cannot move the cut. Nothing is cut when
neither form is present, so an unexpected layout fails the comparison
loudly instead of silently shortening the text. */
_marker=index(got,'\\n\\nLog Extract:\\n');
if _marker=0 then _marker=index(got,'\n\nLog Extract:\n');
if _marker>1 then got=substr(got,1,_marker-1);
else if _marker=1 then got='';
/* unescape what mp_abort escaped, so the comparison is against the text the
hook raised rather than against its JSON form. This is independent of the
tail strip: a response that carries no appended extract is still escaped. */
+2 -2
View File
@@ -166,12 +166,12 @@ run;
)
/**
* Test 8 - the six-character separator, as the estate emits it
* Test 8 - a fully escaped separator, as the estate emits it
*/
%let f8=%mf_getuniquefileref();
data _null_;
file &f8 termstr=crlf;
put '{"sasjsAbort": [{"MSG": "doubled\\n\\nLog Extract:\nNOTE: a log line"}]}';
put '{"sasjsAbort": [{"MSG": "doubled\\n\\nLog Extract:\\nNOTE: a log line"}]}';
run;
%mpe_assert_abort(
+4 -1
View File
@@ -163,7 +163,10 @@
%else %do;
/* no helper on the autocall path: the abort still names the program and
state, and carries the child's message, rather than arriving blank */
%let _mpe_abortmsg=%superq(program) did not complete: state=%superq(state) %superq(msg);
%if "%superq(msg)" ne "" %then
%let _mpe_abortmsg=%superq(program) did not complete: state=%superq(state) %superq(msg);
%else
%let _mpe_abortmsg=%superq(program) did not complete: state=%superq(state);
%end;
%mp_abort(
iftrue=(1=1)