Skip to content

Commit c79f20f

Browse files
fglockcodex
andcommitted
feat(regex): make callback mutations transactional
Snapshot ordinary callback-visible scalar, array, and hash state for each matcher attempt. Restore it when the overall match fails or a callback throws, while preserving Perl's committed mutations when another alternative succeeds. Hide internal callback IDs from regex stringification without breaking callback remapping for embedded patterns. Add standard-Perl-valid regression coverage and update the Phase 36 delivery plan with the verified 42/42 rxcode baseline. Generated with [Codex](https://openai.com/codex) Co-Authored-By: Codex <codex@openai.com>
1 parent 5a2afed commit c79f20f

10 files changed

Lines changed: 312 additions & 17 deletions

File tree

dev/design/phase36-regex-parity.md

Lines changed: 16 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -241,11 +241,13 @@ timing delta is a regression only after a serialized same-commit reproduction.
241241
### Current Status: Stage 36.4 in progress
242242

243243
The merged Joni dynamic-pattern engine establishes the Stage 36.5 execution
244-
seam. The current same-commit Stage 36.4 core baseline is `rxcode.t` 39/42 and
245-
`reg_eval_scope.t` 22/49, with no timeout or incomplete file. Callback
246-
exceptions now restore dynamic locals, provisional match state, and `$^R` on
247-
both execution backends even though the matcher cannot create an unwind token
248-
for a callout that throws.
244+
seam. The current Stage 36.4 core baseline is `rxcode.t` 42/42 and
245+
`reg_eval_scope.t` 22/49, with no timeout or incomplete file. Matcher-owned
246+
transactions now restore ordinary scalar, array, and hash mutations when the
247+
overall match fails, retain mutations from abandoned alternatives when another
248+
alternative succeeds, and commit successful matches. Callback exceptions also
249+
restore dynamic locals, provisional match state, and `$^R` on both execution
250+
backends. Regex stringification no longer exposes private callback IDs.
249251

250252
### Completed stages
251253

@@ -260,22 +262,22 @@ for a callout that throws.
260262

261263
### Next steps
262264

263-
1. Add matcher-owned mutation checkpoints so writes made by a callback on a
264-
path that later backtracks are restored. This is the direct blocker for
265-
`rxcode.t` assertions 26 and 34.
266-
2. Complete callback lexical pragma, caller-frame, and control-flow isolation
265+
1. Complete callback lexical pragma, caller-frame, and control-flow isolation
267266
exposed by `reg_eval_scope.t`, without changing its thread wrapper.
268-
3. Extend the callback semantic matrix with interruption, timeout, and nested
267+
2. Extend the callback semantic matrix with interruption, timeout, nested
269268
exception paths; require identical JVM/interpreter cleanup.
269+
3. Define and implement the mutation policy for tied, magical, shared, and
270+
readonly values; ordinary values are now transactionally covered.
270271
4. Complete the merged dynamic-pattern validation gates, then mark Stage 36.5
271272
complete and proceed to the remaining declarative parity slices.
272273

273274
### Open blockers
274275

275-
- Several callback-localization and dynamic-pattern capture rules still require
276-
standard-Perl differential evidence.
277-
- Ordinary scalar and aggregate mutations performed by a callback are not yet
278-
transactionally restored when the matcher abandons that path.
276+
- Callback lexical pragmata, caller frames, and non-local control-flow
277+
boundaries still differ from Perl in `reg_eval_scope.t`.
278+
- Tied, magical, shared, and readonly callback mutation rollback remains
279+
intentionally outside the ordinary-value transaction until its exact Perl
280+
behavior is established with differential tests.
279281
- Dynamic `(??{ EXPR })` execution is integrated, but its full Stage 36.5 CPAN
280282
and unchanged-core exit matrix is not yet recorded.
281283
- Partial direct core tests still contain diagnostic, parser, Unicode, and

src/main/java/org/perlonjava/runtime/regex/JoniRegexPattern.java

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -494,14 +494,25 @@ private record Token(int localLevel, RegexState regexState, RuntimeScalar previo
494494
private final int[] byteToChar;
495495
private final List<RuntimeRegexCallback> callbacks;
496496
private final RegexFlags outerFlags;
497+
private final RegexCallbackMutationSnapshot mutations;
497498
private RuntimeScalar completedResult;
498499

499500
PerlCalloutHandler(String input, int[] byteToChar, List<RuntimeRegexCallback> callbacks,
500501
RegexFlags outerFlags) {
502+
this(input, byteToChar, callbacks, outerFlags,
503+
RegexCallbackMutationSnapshot.capture());
504+
}
505+
506+
private PerlCalloutHandler(String input, int[] byteToChar,
507+
List<RuntimeRegexCallback> callbacks,
508+
RegexFlags outerFlags,
509+
RegexCallbackMutationSnapshot mutations) {
501510
this.input = input;
502511
this.byteToChar = byteToChar;
503512
this.callbacks = callbacks;
504513
this.outerFlags = outerFlags;
514+
this.mutations = mutations;
515+
for (RuntimeRegexCallback callback : callbacks) mutations.include(callback.code);
505516
}
506517

507518
@Override
@@ -544,7 +555,8 @@ public DynamicPatternResult executeDynamic(int id, MatchView match) {
544555
: new PerlCalloutHandler(input, byteToChar, nestedCallbacks,
545556
value.value instanceof RuntimeRegex runtimeRegex
546557
&& runtimeRegex.getRegexFlags() != null
547-
? runtimeRegex.getRegexFlags() : outerFlags);
558+
? runtimeRegex.getRegexFlags() : outerFlags,
559+
mutations);
548560
return new DynamicPatternResult(nestedPattern.engineRegex(), nestedHandler,
549561
evaluation.token());
550562
}
@@ -557,19 +569,22 @@ private Evaluation evaluate(RuntimeRegexCallback callback, MatchView match) {
557569
RuntimeScalar rVariable = GlobalVariable.getGlobalVariable(
558570
GlobalContext.encodeSpecialVar("R"));
559571
RuntimeScalar previousR = rVariable.clone();
572+
mutations.include(callback.code);
560573
publishProvisional(match);
561574

562575
try {
563576
RuntimeScalar result = RuntimeCode.apply(new RuntimeScalar(callback.code),
564577
new RuntimeArray(), RuntimeContextType.SCALAR).scalar();
565578
boolean block = callback.kind == RuntimeRegexCallback.Kind.BLOCK;
566579
if (block) rVariable.set(result);
567-
Token token = new Token(localLevel, savedRegex, previousR, result.clone(), block);
580+
Token token = new Token(localLevel, savedRegex, previousR,
581+
result.clone(), block);
568582
return new Evaluation(result, token);
569583
} catch (RuntimeException | Error failure) {
570584
// The matcher cannot register an unwind token when the callout
571585
// itself throws. Restore the provisional match and dynamic
572586
// scope here before the exception crosses an eval boundary.
587+
mutations.restore();
573588
restoreCallbackScope(localLevel, savedRegex, previousR);
574589
throw failure;
575590
}
@@ -586,6 +601,7 @@ public void complete(Object value) {
586601
}
587602

588603
void finish(boolean matched) {
604+
if (!matched) mutations.restore();
589605
if (matched && completedResult != null) {
590606
GlobalVariable.getGlobalVariable(GlobalContext.encodeSpecialVar("R"))
591607
.set(completedResult);

src/main/java/org/perlonjava/runtime/regex/RuntimeRegex.java

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3066,6 +3066,13 @@ private static ResolvedRegex resolveRegexWithOrigin(RuntimeScalar quotedRegex) {
30663066
@Override
30673067
public String toString() {
30683068
// Construct the Perl-like regex string with flags
3069+
String displayPattern = executableCallbacks.isEmpty()
3070+
? patternString
3071+
: RuntimeRegexTemplate.displayPattern(patternString);
3072+
return "(?^" + regexFlags.toFlagString() + ":" + displayPattern + ")";
3073+
}
3074+
3075+
String toExecutableString() {
30693076
return "(?^" + regexFlags.toFlagString() + ":" + patternString + ")";
30703077
}
30713078

src/main/java/org/perlonjava/runtime/regex/RuntimeRegexTemplate.java

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ public static RuntimeScalar build(RuntimeList parts) {
5353
}
5454
} else if (scalar.value instanceof RuntimeRegex regex
5555
&& !regex.executableCallbacks.isEmpty()) {
56-
appendEmbeddedRegex(pattern, callbacks, regex.toString(), regex.executableCallbacks);
56+
appendEmbeddedRegex(pattern, callbacks, regex.toExecutableString(), regex.executableCallbacks);
5757
} else if (scalar.value instanceof RuntimeRegexTemplate template) {
5858
appendEmbeddedRegex(pattern, callbacks, template.pattern, template.callbacks);
5959
} else {
@@ -96,6 +96,20 @@ List<RuntimeRegexCallback> callbacks() {
9696
return callbacks;
9797
}
9898

99+
static String displayPattern(String executablePattern) {
100+
if (executablePattern == null || executablePattern.isEmpty()) {
101+
return executablePattern;
102+
}
103+
Matcher matcher = CALLOUT_ID.matcher(executablePattern);
104+
StringBuilder display = new StringBuilder();
105+
while (matcher.find()) {
106+
String replacement = "DYNAMIC".equals(matcher.group(1)) ? "(??{})" : "(?{})";
107+
matcher.appendReplacement(display, Matcher.quoteReplacement(replacement));
108+
}
109+
matcher.appendTail(display);
110+
return display.toString();
111+
}
112+
99113
@Override
100114
public String toString() {
101115
return pattern;
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
package org.perlonjava.runtime.runtimetypes;
2+
3+
import java.util.ArrayDeque;
4+
import java.util.IdentityHashMap;
5+
import java.util.Map;
6+
7+
/** Matcher-owned save stack for Perl regex callback mutations. */
8+
public final class RegexCallbackMutationSnapshot {
9+
private final IdentityHashMap<RuntimeScalar, Object> scalars = new IdentityHashMap<>();
10+
private final IdentityHashMap<RuntimeArray, Object> arrays = new IdentityHashMap<>();
11+
private final IdentityHashMap<RuntimeHash, Object> hashes = new IdentityHashMap<>();
12+
13+
private final IdentityHashMap<RuntimeBase, Boolean> seen = new IdentityHashMap<>();
14+
15+
private RegexCallbackMutationSnapshot() {
16+
ArrayDeque<RuntimeBase> work = new ArrayDeque<>();
17+
for (Map.Entry<String, RuntimeScalar> entry : GlobalVariable.globalVariables.entrySet()) {
18+
if (isOrdinaryPackageScalar(entry.getKey())) work.add(entry.getValue());
19+
}
20+
addAll(work, GlobalVariable.globalArrays.values());
21+
addAll(work, GlobalVariable.globalHashes.values());
22+
capture(work);
23+
}
24+
25+
public void include(RuntimeCode callback) {
26+
ArrayDeque<RuntimeBase> work = new ArrayDeque<>();
27+
if (callback.closedOverVariables != null) addAll(work, callback.closedOverVariables.values());
28+
addAll(work, callback.capturedScalars);
29+
addAll(work, callback.capturedAggregates);
30+
capture(work);
31+
}
32+
33+
private void capture(ArrayDeque<RuntimeBase> work) {
34+
while (!work.isEmpty()) {
35+
RuntimeBase value = work.removeLast();
36+
if (value == null || seen.put(value, Boolean.TRUE) != null) continue;
37+
if (value instanceof RuntimeScalar scalar) {
38+
Object state = scalar.snapshotRegexMutationState();
39+
if (state != null) scalars.put(scalar, state);
40+
if (scalar.value instanceof RuntimeArray array) work.add(array);
41+
else if (scalar.value instanceof RuntimeHash hash) work.add(hash);
42+
else if (scalar.value instanceof RuntimeScalar nested) work.add(nested);
43+
} else if (value instanceof RuntimeArray array) {
44+
Object state = array.snapshotRegexMutationState();
45+
if (state == null) continue;
46+
arrays.put(array, state);
47+
addAll(work, array.elements);
48+
} else if (value instanceof RuntimeHash hash) {
49+
Object state = hash.snapshotRegexMutationState();
50+
if (state == null) continue;
51+
hashes.put(hash, state);
52+
addAll(work, hash.elements.values());
53+
}
54+
}
55+
}
56+
57+
public static RegexCallbackMutationSnapshot capture() {
58+
return new RegexCallbackMutationSnapshot();
59+
}
60+
61+
public void restore() {
62+
for (Map.Entry<RuntimeScalar, Object> entry : scalars.entrySet()) {
63+
entry.getKey().restoreRegexMutationState(entry.getValue());
64+
}
65+
for (Map.Entry<RuntimeArray, Object> entry : arrays.entrySet()) {
66+
entry.getKey().restoreRegexMutationState(entry.getValue());
67+
}
68+
for (Map.Entry<RuntimeHash, Object> entry : hashes.entrySet()) {
69+
entry.getKey().restoreRegexMutationState(entry.getValue());
70+
}
71+
MortalList.flush();
72+
}
73+
74+
private static void addAll(ArrayDeque<RuntimeBase> work,
75+
Iterable<? extends RuntimeBase> values) {
76+
if (values == null) return;
77+
for (RuntimeBase value : values) if (value != null) work.add(value);
78+
}
79+
80+
private static void addAll(ArrayDeque<RuntimeBase> work, RuntimeBase[] values) {
81+
if (values == null) return;
82+
for (RuntimeBase value : values) if (value != null) work.add(value);
83+
}
84+
85+
private static boolean isOrdinaryPackageScalar(String name) {
86+
int separator = name.lastIndexOf("::");
87+
String symbol = separator < 0 ? name : name.substring(separator + 2);
88+
if (symbol.isEmpty() || !Character.isJavaIdentifierStart(symbol.charAt(0))) return false;
89+
for (int i = 1; i < symbol.length(); i++) {
90+
if (!Character.isJavaIdentifierPart(symbol.charAt(i))) return false;
91+
}
92+
return true;
93+
}
94+
}

src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeArray.java

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,52 @@ void resetElementListAfterAutovivification() {
8787
elements = newElementList();
8888
}
8989

90+
Object snapshotRegexMutationState() {
91+
if (threadShared || type == TIED_ARRAY || type == READONLY_ARRAY) return null;
92+
Set<RuntimeScalar> ownedAliases = ownedAliasElements == null ? null
93+
: Collections.newSetFromMap(new IdentityHashMap<>());
94+
if (ownedAliases != null) ownedAliases.addAll(ownedAliasElements);
95+
return new RegexMutationState(new ArrayList<>(elements), type, strictAutovivify,
96+
scalarContextSize, elementsOwned, elementsAliased, ownedAliases, blessId);
97+
}
98+
99+
void restoreRegexMutationState(Object token) {
100+
if (!(token instanceof RegexMutationState state)) return;
101+
deferElementsAddedSince(state.elements);
102+
elements = newElementList(state.elements);
103+
type = state.type;
104+
strictAutovivify = state.strictAutovivify;
105+
scalarContextSize = state.scalarContextSize;
106+
elementsOwned = state.elementsOwned;
107+
elementsAliased = state.elementsAliased;
108+
ownedAliasElements = state.ownedAliasElements == null ? null
109+
: Collections.newSetFromMap(new IdentityHashMap<>());
110+
if (ownedAliasElements != null) ownedAliasElements.addAll(state.ownedAliasElements);
111+
blessId = state.blessId;
112+
markPackageRootedValues(0);
113+
}
114+
115+
private void deferElementsAddedSince(List<RuntimeScalar> saved) {
116+
IdentityHashMap<RuntimeScalar, Integer> retained = new IdentityHashMap<>();
117+
for (RuntimeScalar scalar : saved) {
118+
if (scalar != null) retained.merge(scalar, 1, Integer::sum);
119+
}
120+
List<RuntimeScalar> added = new ArrayList<>();
121+
for (RuntimeScalar scalar : elements) {
122+
if (scalar == null) continue;
123+
Integer count = retained.get(scalar);
124+
if (count == null || count == 0) added.add(scalar);
125+
else if (count == 1) retained.remove(scalar);
126+
else retained.put(scalar, count - 1);
127+
}
128+
MortalList.deferDestroyForContainerClear(added);
129+
}
130+
131+
private record RegexMutationState(List<RuntimeScalar> elements, int type,
132+
boolean strictAutovivify, Integer scalarContextSize,
133+
boolean elementsOwned, boolean elementsAliased,
134+
Set<RuntimeScalar> ownedAliasElements, int blessId) {}
135+
90136
private static final class RuntimeArrayElementList extends ArrayList<RuntimeScalar> {
91137
private final RuntimeArray owner;
92138

src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeHash.java

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,46 @@ void resetElementMapAfterAutovivification() {
8383
elements = newElementMap();
8484
}
8585

86+
Object snapshotRegexMutationState() {
87+
if (threadShared || type == TIED_HASH || type == READONLY_HASH) return null;
88+
return new RegexMutationState(new LinkedHashMap<>(elements),
89+
byteKeys == null ? null : new HashSet<>(byteKeys), type, blessId,
90+
taintEnvironmentAliasDescription);
91+
}
92+
93+
void restoreRegexMutationState(Object token) {
94+
if (!(token instanceof RegexMutationState state)) return;
95+
deferValuesAddedSince(state.elements.values());
96+
elements = newElementMap(state.elements);
97+
byteKeys = state.byteKeys == null ? null : new HashSet<>(state.byteKeys);
98+
type = state.type;
99+
blessId = state.blessId;
100+
taintEnvironmentAliasDescription = state.taintEnvironmentAliasDescription;
101+
if (isPackageRootedHash()) {
102+
for (RuntimeScalar value : elements.values()) markPackageRootedValue(value);
103+
}
104+
}
105+
106+
private void deferValuesAddedSince(Collection<RuntimeScalar> saved) {
107+
IdentityHashMap<RuntimeScalar, Integer> retained = new IdentityHashMap<>();
108+
for (RuntimeScalar scalar : saved) {
109+
if (scalar != null) retained.merge(scalar, 1, Integer::sum);
110+
}
111+
List<RuntimeScalar> added = new ArrayList<>();
112+
for (RuntimeScalar scalar : elements.values()) {
113+
if (scalar == null) continue;
114+
Integer count = retained.get(scalar);
115+
if (count == null || count == 0) added.add(scalar);
116+
else if (count == 1) retained.remove(scalar);
117+
else retained.put(scalar, count - 1);
118+
}
119+
MortalList.deferDestroyForContainerClear(added);
120+
}
121+
122+
private record RegexMutationState(Map<String, RuntimeScalar> elements,
123+
Set<String> byteKeys, int type, int blessId,
124+
String taintEnvironmentAliasDescription) {}
125+
86126
private static final class RuntimeHashElementMap extends StableHashMap<String, RuntimeScalar> {
87127
private final RuntimeHash owner;
88128

src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeScalar.java

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -707,6 +707,34 @@ public RuntimeScalar clone() {
707707
return new RuntimeScalar(this);
708708
}
709709

710+
/** Capture a plain scalar payload for regex callback backtracking. */
711+
Object snapshotRegexMutationState() {
712+
if (type < INTEGER || type > BOOLEAN) return null;
713+
return new RegexMutationState(type, value, utf8UncheckedOctets, tainted,
714+
numericLiteralText, numericContextSeen, firstClassRegexScalar,
715+
formatPictureTainted);
716+
}
717+
718+
void restoreRegexMutationState(Object token) {
719+
if (!(token instanceof RegexMutationState state)) return;
720+
type = state.type;
721+
value = state.value;
722+
utf8UncheckedOctets = state.utf8UncheckedOctets;
723+
tainted = state.tainted;
724+
numericLiteralText = state.numericLiteralText;
725+
numericContextSeen = state.numericContextSeen;
726+
firstClassRegexScalar = state.firstClassRegexScalar;
727+
formatPictureTainted = state.formatPictureTainted;
728+
RuntimePosLvalue.invalidatePos(this);
729+
refreshSubstrLvalues();
730+
}
731+
732+
private record RegexMutationState(int type, Object value,
733+
boolean utf8UncheckedOctets, boolean tainted,
734+
String numericLiteralText, boolean numericContextSeen,
735+
boolean firstClassRegexScalar,
736+
boolean formatPictureTainted) {}
737+
710738
public int countElements() {
711739
return 1;
712740
}

0 commit comments

Comments
 (0)