diff --git a/tlatools/org.lamport.tlatools/customBuild.xml b/tlatools/org.lamport.tlatools/customBuild.xml index d24e9f77a7..ae4409b1b0 100644 --- a/tlatools/org.lamport.tlatools/customBuild.xml +++ b/tlatools/org.lamport.tlatools/customBuild.xml @@ -505,6 +505,10 @@ Running tests across ${threadLimit} threads + + continuationTraces = new java.util.concurrent.ConcurrentHashMap<>(); + + /** Whether a trace should still be printed for this property's violation. */ + public static boolean continuationTraceAllowed(final String property) { + if (continuationTraceLimit < 0) { + return true; + } + final int n = continuationTraces.computeIfAbsent(property == null ? "" : property, + k -> new java.util.concurrent.atomic.AtomicInteger()).incrementAndGet(); + return n <= continuationTraceLimit; + } + + /** + * Whether the violation of this property being reported now will get no + * trace: TLC continues past violations and the property already printed + * as many traces as the limit allows. Read before + * {@link #continuationTraceAllowed} counts the report, under the same lock. + */ + public static boolean continuationTraceCapped(final String property) { + if (!continuation || continuationTraceLimit < 0) { + return false; + } + final java.util.concurrent.atomic.AtomicInteger n = continuationTraces.get(property == null ? "" : property); + return (n == null ? 0 : n.get()) >= continuationTraceLimit; + } + + public static void resetContinuationTraces() { + continuationTraces.clear(); + } + // Prints only the state difference in state traces public static boolean printDiffsOnly = false; diff --git a/tlatools/org.lamport.tlatools/src/tlc2/basis/GraphStore.java b/tlatools/org.lamport.tlatools/src/tlc2/basis/GraphStore.java new file mode 100644 index 0000000000..be6f6e94b6 --- /dev/null +++ b/tlatools/org.lamport.tlatools/src/tlc2/basis/GraphStore.java @@ -0,0 +1,780 @@ +/******************************************************************************* + * Copyright (c) 2026 Basis Research Institute. All rights reserved. + * + * The MIT License (MIT) + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies + * of the Software, and to permit persons to whom the Software is furnished to do + * so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + ******************************************************************************/ +package tlc2.basis; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.IOException; +import java.io.RandomAccessFile; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.LongAdder; + +import tla2sany.parser.SyntaxTreeNode; +import tla2sany.semantic.SemanticNode; +import tlc2.tool.Action; +import tlc2.tool.TLCState; +import tlc2.util.BitVector; +import tlc2.util.Context; +import tlc2.util.IStateWriter; +import tlc2.util.LongVec; +import tlc2.value.ValueInputStream; +import tlc2.value.ValueOutputStream; + +/** + * What TLC computes and throws away, kept: every reached state's content, + * how it was first reached, its predecessors, and the guard conjuncts that + * kept transitions from firing. + * + *

+ * TLC itself stores only fingerprints ({@code FPSet}) and one parent pointer + * per state ({@code TLCTrace}); reconstructing a state means re-running the + * next-state relation from an initial state. This store sits on the + * {@link IStateWriter} hook the checker already calls on every edge + * ({@code ModelChecker.isSeenState}, {@code Worker.addElement}) and on every + * guard that evaluated false ({@code Worker.addUnsatisfiedState}, routed here + * because {@link #isConstrained()} is true), so the checker core is + * untouched. + * + *

+ * State content goes to one append-only file under the metadir, serialised + * the way {@code DiskStateQueue} serialises states; the index (fingerprint + * to offset, level and first predecessor) and the predecessor lists stay in + * memory. Successors a constraint excluded are kept as well, content and + * edge, since TLC checks invariants on them and an invariant sweep must too. + * Blocked guards are tallied, not logged: per (action, conjunct) + * a count, one example state and one example binding. The tallies take no + * lock (a false guard is reported far more often than an edge); edges and + * states are recorded under the store's lock, which costs a run with many + * edges per state noticeable time (about 1.4x on a spec with 3.4M edges, + * one worker or four). + * + *

+ * The in-memory part is not bounded: roughly a hundred bytes of heap per + * state and fifty per edge, on top of the checker's own. It suits the specs + * one iterates on interactively; for a large run, open the resident with + * {@code store: false} (no store, no store queries) or give the JVM the heap. + */ +public final class GraphStore implements IStateWriter { + + /** How a state was first reached, and where its content is. */ + private static final class Entry { + final long offset; + final int length; + final int level; + /** Fingerprint of the state this one was first reached from, or 0 for an initial state. */ + final long predecessor; + /** The action that first reached it, or -1. */ + final int action; + + Entry(long offset, int length, int level, long predecessor, int action) { + this.offset = offset; + this.length = length; + this.level = level; + this.predecessor = predecessor; + this.action = action; + } + } + + /** One guard conjunct's (or constraint's) tally of the transitions it disabled. */ + public static final class Blocked { + public final int actionId; + public final String action; + /** {@code guard}: a conjunct of the action evaluated false; {@code constraint}: a state or action constraint excluded the successor. */ + public final String kind; + public final String location; + public final String text; + /** The count when this row was read ({@link GraphStore#blocked()} returns snapshots). */ + public long count; + public long exampleFp; + public Map exampleBindings; + private final LongAdder tally = new LongAdder(); + + Blocked(int actionId, String action, String kind, String location, String text) { + this.actionId = actionId; + this.action = action; + this.kind = kind; + this.location = location; + this.text = text; + } + + private Blocked snapshot() { + final Blocked b = new Blocked(actionId, action, kind, location, text); + b.count = tally.sum(); + b.exampleFp = exampleFp; + b.exampleBindings = exampleBindings; + return b; + } + } + + /** + * What a tally is kept per: the action, the conjunct's node (by identity: + * it is the same node on every evaluation, so no text or location is + * built on the hot path) and whether it is a guard or a constraint. + */ + private static final class TallyKey { + final int actionId; + final SemanticNode pred; + final boolean constraint; + + TallyKey(int actionId, SemanticNode pred, boolean constraint) { + this.actionId = actionId; + this.pred = pred; + this.constraint = constraint; + } + + @Override + public boolean equals(final Object o) { + if (!(o instanceof TallyKey)) { + return false; + } + final TallyKey k = (TallyKey) o; + return actionId == k.actionId && pred == k.pred && constraint == k.constraint; + } + + @Override + public int hashCode() { + return 31 * (31 * actionId + System.identityHashCode(pred)) + (constraint ? 1 : 0); + } + } + + private final File file; + private final RandomAccessFile content; + private final Map index = new HashMap<>(); + /** + * Successors a state or action constraint excluded, by fingerprint: they + * are not in the model and never expanded, but TLC checks every invariant + * on them, so their content is kept for the invariant sweeps. + */ + private final Map excludedIndex = new HashMap<>(); + /** Fingerprint to (excluded successor fp, action id) pairs, each edge once. */ + private final Map excludedEdges = new HashMap<>(); + /** Fingerprint to (predecessor fp, action id, flags) triples. */ + private final Map predecessors = new HashMap<>(); + private final Map actions = new HashMap<>(); + /** + * Tallied without the store's lock: every worker reports every false + * guard, many times more often than it reports an edge. + */ + private final ConcurrentHashMap blocked = new ConcurrentHashMap<>(); + private final List initial = new ArrayList<>(); + /** + * Initial states a state constraint excluded, kept in + * {@link #excludedIndex}: TLC checks invariants and state-level + * properties on them too. + */ + private final java.util.Set excludedInitial = new java.util.LinkedHashSet<>(); + private long edges; + private final LongAdder unsatisfied = new LongAdder(); + private final LongAdder excluded = new LongAdder(); + private TLCState empty; + /** Serialised states not yet written to {@link #content}; flushed before a read. */ + private final ByteArrayOutputStream pending = new ByteArrayOutputStream(1 << 16); + /** Bytes in {@link #content} plus {@link #pending}: the next state's offset. */ + private long length; + private static final int FLUSH_AT = 1 << 20; + + public GraphStore(final String metadir) throws IOException { + this.file = new File(metadir, "basis.states"); + this.content = new RandomAccessFile(this.file, "rw"); + this.content.setLength(0); + } + + /** The state every stored one is read into a copy of. */ + private TLCState empty() { + if (empty == null) { + empty = TLCState.Empty.createEmpty(); + } + return empty; + } + + // ─── what the checker writes ──────────────────────────────────────── + + @Override + public void writeState(final TLCState state) { + // An initial state. + final long fp = state.fingerPrint(); + final byte[] data = serialise(state); + synchronized (this) { + if (!index.containsKey(fp)) { + store(fp, data, 1, 0, -1); + initial.add(fp); + } + } + } + + @Override + public void writeExcludedInitial(final TLCState state) { + final long fp = state.fingerPrint(); + final byte[] data = serialise(state); + synchronized (this) { + if (!excludedIndex.containsKey(fp)) { + excludedIndex.put(fp, new Entry(append(data), data.length, 1, 0, -1)); + } + excludedInitial.add(fp); + } + } + + @Override + public synchronized void writeState(final TLCState state, final TLCState successor, final short stateFlags) { + writeState(state, successor, stateFlags, (Action) null); + } + + @Override + public void writeState(final TLCState state, final TLCState successor, final short stateFlags, + final Action action) { + if (isSet(stateFlags, IsNotInModel)) { + return; + } + // Fingerprinting and serialising run outside the lock: every worker + // writes every edge here, so the lock covers only the maps. + final long from = state.fingerPrint(); + final long to = successor.fingerPrint(); + final int actionId = action == null ? -1 : action.getId(); + // Keep the content of the write that won TLC's fingerprint-set put + // (IsUnseen): that is the state TLC enqueued and whose successors the + // store records. Under a VIEW or SYMMETRY another worker may reach the + // same fingerprint with a different concrete state, and its (IsSeen) + // write can take this lock first. + final byte[] data = isSet(stateFlags, IsUnseen) ? serialise(successor) : null; + synchronized (this) { + if (action != null) { + actions.putIfAbsent(actionId, action); + } + edges++; + if (data != null && !index.containsKey(to)) { + final Entry pred = index.get(from); + store(to, data, pred == null ? 2 : pred.level + 1, from, actionId); + } + final LongVec preds = predecessors.computeIfAbsent(to, k -> new LongVec(4)); + preds.addElement(from); + preds.addElement(actionId); + preds.addElement(stateFlags); + } + } + + /** + * A state or action constraint {@code pred} excluded {@code successor}: + * the worker calls this only for constraints. Tallied apart from the + * guards, since the successor was generated and then dropped. + */ + @Override + public void writeState(final TLCState state, final TLCState successor, final short stateFlags, + final Action action, final SemanticNode pred) { + excluded.increment(); + tally(state, action, pred, null, true); + writeExcluded(state, successor, action); + } + + /** + * Keep {@code successor}, which a constraint excluded when {@code action} + * generated it from {@code state}: its content (once per fingerprint) and + * the edge (once per source and action). The worker reports an excluded + * successor once per constraint it fails; the repeats add nothing. + */ + public void writeExcluded(final TLCState state, final TLCState successor, final Action action) { + final long from = state.fingerPrint(); + final long to = successor.fingerPrint(); + final int actionId = action == null ? -1 : action.getId(); + final boolean known; + synchronized (this) { + known = excludedIndex.containsKey(to); + if (known && hasExcludedEdge(from, to, actionId)) { + return; + } + } + final byte[] data = known ? null : serialise(successor); + synchronized (this) { + if (action != null) { + actions.putIfAbsent(actionId, action); + } + if (data != null && !excludedIndex.containsKey(to)) { + final Entry pred = index.get(from); + excludedIndex.put(to, new Entry(append(data), data.length, pred == null ? 2 : pred.level + 1, from, + actionId)); + } + if (!hasExcludedEdge(from, to, actionId)) { + final LongVec edges = excludedEdges.computeIfAbsent(from, k -> new LongVec(2)); + edges.addElement(to); + edges.addElement(actionId); + } + } + } + + /** Caller holds the lock. */ + private boolean hasExcludedEdge(final long from, final long to, final int actionId) { + final LongVec edges = excludedEdges.get(from); + if (edges == null) { + return false; + } + for (int i = 0; i < edges.size(); i += 2) { + if (edges.elementAt(i) == to && edges.elementAt(i + 1) == actionId) { + return true; + } + } + return false; + } + + /** + * A guard conjunct evaluated false: the transition {@code action} was not + * enabled at {@code state} because of {@code pred}, under the quantifier + * bindings in {@code c}. Tallied per (action, conjunct). + */ + @Override + public void writeUnsatisfied(final TLCState state, final Action action, final TLCState successor, + final SemanticNode pred, final Context c) { + unsatisfied.increment(); + tally(state, action, pred, c, false); + } + + private void tally(final TLCState state, final Action action, final SemanticNode pred, final Context c, + final boolean constraint) { + final int actionId = action == null ? -1 : action.getId(); + final TallyKey key = new TallyKey(actionId, pred, constraint); + Blocked b = blocked.get(key); + if (b == null) { + // The row's text, location and example are built once, by + // whichever worker reports the conjunct first. + b = blocked.computeIfAbsent(key, k -> { + final Blocked n = new Blocked(actionId, action == null ? "?" : action.getNameOfDefault(), + constraint ? "constraint" : "guard", pred == null ? "?" : String.valueOf(pred.getLocation()), + text(pred)); + try { + n.exampleFp = state.fingerPrint(); + } catch (final RuntimeException e) { + n.exampleFp = 0; + } + if (c != null) { + final Map bindings = new HashMap<>(); + c.toMap().forEach((name, v) -> bindings.put(name.toString(), v.toString())); + n.exampleBindings = bindings; + } + return n; + }); + } + b.tally.increment(); + } + + /** The source text of a semantic node, or its location when the parse tree is gone. */ + public static String text(final SemanticNode node) { + if (node == null) { + return ""; + } + if (node.getTreeNode() instanceof SyntaxTreeNode) { + return ((SyntaxTreeNode) node.getTreeNode()).getHumanReadableImage(); + } + return String.valueOf(node.getLocation()); + } + + @Override + public void writeState(final TLCState state, final TLCState successor, final short stateFlags, + final Visualization visualization) { + // Stuttering steps carry no new state. + } + + @Override + public void writeState(final TLCState state, final TLCState successor, final BitVector actionChecks, + final int from, final int length, final short stateFlags) { + writeState(state, successor, stateFlags, (Action) null); + } + + @Override + public void writeState(final TLCState state, final TLCState successor, final BitVector actionChecks, + final int from, final int length, final short stateFlags, final Visualization visualization) { + writeState(state, successor, stateFlags, (Action) null); + } + + /** + * The checker closes its state writer when a run ends; the store outlives + * the run, so this only flushes. {@link #dispose()} releases it. + */ + @Override + public synchronized void close() { + try { + flush(); + content.getFD().sync(); + } catch (final IOException e) { + // Nothing to report to. + } + } + + /** + * Release the store for good: close its file, delete it, and delete its + * directory when nothing else is left in it (a refresh's own metadir; the + * checker's metadir holds TLC's files too and stays). + */ + public synchronized void dispose() { + try { + content.close(); + } catch (final IOException e) { + // Deleting below is what matters. + } + pending.reset(); + delete(file); + final File dir = file.getParentFile(); + if (dir != null) { + final String[] left = dir.list(); + if (left != null && left.length == 0) { + delete(dir); + } + } + } + + /** + * Delete {@code f}, retrying briefly: on Windows another process (a virus + * scanner, the indexer) can hold a freshly written file open for a moment, + * and the delete fails until it lets go. Left to the JVM's exit otherwise. + */ + static void delete(final File f) { + for (int attempt = 0; attempt < 20; attempt++) { + if (f.delete() || !f.exists()) { + return; + } + try { + Thread.sleep(25); + } catch (final InterruptedException e) { + Thread.currentThread().interrupt(); + break; + } + } + f.deleteOnExit(); + } + + @Override + public String getDumpFileName() { + return file.getPath(); + } + + @Override + public boolean isNoop() { + return false; + } + + @Override + public boolean isDot() { + return false; + } + + /** True, so the worker routes blocked guards here. */ + @Override + public boolean isConstrained() { + return true; + } + + @Override + public synchronized void snapshot() throws IOException { + flush(); + content.getFD().sync(); + } + + // ─── storing and reading content ──────────────────────────────────── + + /** + * A state's variable values as bytes. Only the values: the TLCState + * header (worker id, uid, level) is unset on the states the writer hook + * receives, and the nat encodings reject negative values. + */ + private static byte[] serialise(final TLCState state) { + final Serialiser ser = SERIALISER.get(); + try { + // The stream first: resetting it flushes what it still buffers, + // which after a write that threw part way is a partial value that + // must not land in front of this state. + ser.vos.reset(); + ser.bytes.reset(); + for (final tla2sany.semantic.OpDeclNode var : state.getVars()) { + final tlc2.value.IValue value = state.lookup(var.getName()); + if (value == null) { + throw new IOException("unassigned variable " + var.getName() + " in a stored state"); + } + value.write(ser.vos); + } + ser.vos.reset(); + return ser.bytes.toByteArray(); + } catch (final IOException e) { + throw new RuntimeException("basis.states: " + e.getMessage(), e); + } + } + + /** + * One per worker thread: a value stream costs an 8 KiB buffer to build, + * and every stored state is serialised on the worker that reached it. + */ + private static final class Serialiser { + final ByteArrayOutputStream bytes = new ByteArrayOutputStream(256); + final ValueOutputStream vos; + + Serialiser() { + try { + vos = new ValueOutputStream(bytes, false); + } catch (final IOException e) { + throw new RuntimeException(e); + } + } + } + + private static final ThreadLocal SERIALISER = ThreadLocal.withInitial(Serialiser::new); + + /** Append serialised content; the file write is batched. Caller holds the lock. */ + private void store(final long fp, final byte[] data, final int level, final long predecessor, + final int action) { + index.put(fp, new Entry(append(data), data.length, level, predecessor, action)); + } + + /** Append serialised content and return its offset. Caller holds the lock. */ + private long append(final byte[] data) { + final long offset = length; + pending.write(data, 0, data.length); + length += data.length; + if (pending.size() >= FLUSH_AT) { + try { + flush(); + } catch (final IOException e) { + throw new RuntimeException("basis.states: " + e.getMessage(), e); + } + } + return offset; + } + + /** A state in the model, else an excluded successor, else null. Caller holds the lock. */ + private Entry entry(final long fp) { + final Entry e = index.get(fp); + return e != null ? e : excludedIndex.get(fp); + } + + private void flush() throws IOException { + if (pending.size() == 0) { + return; + } + content.seek(content.length()); + pending.writeTo(new java.io.OutputStream() { + @Override + public void write(final int b) throws IOException { + content.write(b); + } + + @Override + public void write(final byte[] b, final int off, final int len) throws IOException { + content.write(b, off, len); + } + }); + pending.reset(); + } + + /** The stored state (in the model, or an excluded successor) with this fingerprint, or null. */ + public synchronized TLCState read(final long fp) { + final Entry e = entry(fp); + if (e == null) { + return null; + } + try { + flush(); + final byte[] data = new byte[e.length]; + content.seek(e.offset); + content.readFully(data); + final ValueInputStream vis = new ValueInputStream(new ByteArrayInputStream(data)); + TLCState state = empty().createEmpty(); + for (final tla2sany.semantic.OpDeclNode var : state.getVars()) { + state = state.bind(var.getName(), vis.read()); + } + vis.close(); + return state; + } catch (final IOException e2) { + throw new RuntimeException("basis.states: " + e2.getMessage(), e2); + } + } + + public synchronized boolean contains(final long fp) { + return index.containsKey(fp); + } + + public synchronized Integer level(final long fp) { + final Entry e = entry(fp); + return e == null ? null : e.level; + } + + /** Every stored fingerprint, in no particular order. */ + public synchronized long[] fingerprints() { + final long[] out = new long[index.size()]; + int i = 0; + for (final Long fp : index.keySet()) { + out[i++] = fp; + } + return out; + } + + /** + * The fingerprints from an initial state to {@code fp} along first + * predecessors, with the action id taken at each step (-1 for the initial + * state). Null when {@code fp} is not stored. + */ + public synchronized long[][] pathTo(final long fp) { + if (entry(fp) == null) { + return null; + } + final List reversed = new ArrayList<>(); + long cur = fp; + while (true) { + // An excluded successor ends a path; every state before it is in the model. + final Entry e = cur == fp ? entry(cur) : index.get(cur); + if (e == null) { + break; + } + reversed.add(new long[] { cur, e.action }); + if (e.action < 0 || e.predecessor == cur) { + break; + } + cur = e.predecessor; + } + final long[][] path = new long[reversed.size()][]; + for (int i = 0; i < path.length; i++) { + path[i] = reversed.get(path.length - 1 - i); + } + return path; + } + + /** Whether {@code fp} was kept as an excluded successor (it may also be in the model). */ + public synchronized boolean isExcluded(final long fp) { + return excludedIndex.containsKey(fp); + } + + /** The excluded successors not also in the model: TLC checked invariants on them, and never expanded them. */ + public synchronized long[] excludedFingerprints() { + final List out = new ArrayList<>(); + for (final Long fp : excludedIndex.keySet()) { + if (!index.containsKey(fp)) { + out.add(fp); + } + } + final long[] a = new long[out.size()]; + for (int i = 0; i < a.length; i++) { + a[i] = out.get(i); + } + return a; + } + + /** (excluded successor fp, action id) pairs generated from {@code fp}. */ + public synchronized long[][] excludedSuccessors(final long fp) { + final LongVec v = excludedEdges.get(fp); + if (v == null) { + return new long[0][]; + } + final long[][] out = new long[v.size() / 2][]; + for (int i = 0; i < out.length; i++) { + out[i] = new long[] { v.elementAt(2 * i), v.elementAt(2 * i + 1) }; + } + return out; + } + + /** Excluded successors kept, whether or not they are also in the model. */ + public synchronized long excludedStates() { + return excludedIndex.size(); + } + + /** (predecessor fp, action id, flags) triples recorded into {@code fp}. */ + public synchronized long[][] predecessorsOf(final long fp) { + final LongVec v = predecessors.get(fp); + if (v == null) { + return new long[0][]; + } + final long[][] out = new long[v.size() / 3][]; + for (int i = 0; i < out.length; i++) { + out[i] = new long[] { v.elementAt(3 * i), v.elementAt(3 * i + 1), v.elementAt(3 * i + 2) }; + } + return out; + } + + public synchronized Action action(final int id) { + return actions.get(id); + } + + /** Snapshots of the tallies, guards and constraints alike. */ + public List blocked() { + final List out = new ArrayList<>(); + for (final Blocked b : blocked.values()) { + out.add(b.snapshot()); + } + out.sort((a, b) -> Long.compare(b.count, a.count)); + return out; + } + + /** The initial states' fingerprints, in the order they were written. */ + public synchronized long[] initialFingerprints() { + final long[] out = new long[initial.size()]; + for (int i = 0; i < out.length; i++) { + out[i] = initial.get(i); + } + return out; + } + + /** The initial states a state constraint excluded, in the order they were written. */ + public synchronized long[] excludedInitialFingerprints() { + final long[] out = new long[excludedInitial.size()]; + int i = 0; + for (final Long fp : excludedInitial) { + out[i++] = fp; + } + return out; + } + + /** The variable names the stored states were written with, in order. */ + public synchronized String[] variableNames() { + final TLCState e = empty(); + final tla2sany.semantic.OpDeclNode[] vars = e.getVars(); + final String[] out = new String[vars.length]; + for (int i = 0; i < vars.length; i++) { + out[i] = vars[i].getName().toString(); + } + return out; + } + + public synchronized long states() { + return index.size(); + } + + public synchronized long edges() { + return edges; + } + + /** Guard conjuncts that evaluated false. */ + public long unsatisfied() { + return unsatisfied.sum(); + } + + /** Successors a state or action constraint excluded. */ + public long excluded() { + return excluded.sum(); + } + + public synchronized long initialStates() { + return initial.size(); + } + + public synchronized long bytes() { + return length; + } +} diff --git a/tlatools/org.lamport.tlatools/src/tlc2/basis/Incremental.java b/tlatools/org.lamport.tlatools/src/tlc2/basis/Incremental.java new file mode 100644 index 0000000000..2455b230f1 --- /dev/null +++ b/tlatools/org.lamport.tlatools/src/tlc2/basis/Incremental.java @@ -0,0 +1,997 @@ +/******************************************************************************* + * Copyright (c) 2026 Basis Research Institute. All rights reserved. + * + * The MIT License (MIT) + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies + * of the Software, and to permit persons to whom the Software is furnished to do + * so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + ******************************************************************************/ +package tlc2.basis; + +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeMap; + +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; + +import tla2sany.semantic.ASTConstants; +import tla2sany.semantic.ExprNode; +import tla2sany.semantic.FormalParamNode; +import tla2sany.semantic.OpApplNode; +import tla2sany.semantic.OpArgNode; +import tla2sany.semantic.OpDeclNode; +import tla2sany.semantic.OpDefNode; +import tla2sany.semantic.SemanticNode; +import tla2sany.semantic.Subst; +import tla2sany.semantic.SubstInNode; +import tla2sany.semantic.SymbolNode; +import tlc2.tool.Action; +import tlc2.tool.StateVec; +import tlc2.tool.TLCState; +import tlc2.tool.impl.Tool; +import tlc2.util.Context; +import tlc2.util.Vect; + +/** + * Re-explore after a spec edit, doing only the work the edit requires. + * + *

+ * The edited spec is parsed into a new {@link Tool}. Every action, invariant, + * initial predicate and constraint gets a signature: its + * own source text, the text of every user definition it reaches + * (transitively, across modules), and the values its context binds (the + * {@code p} of an action split out of {@code \E p \in S : A(p)}). Actions are + * paired with the old ones by name and signature; invariants by name. If the + * variables, the initial predicate or a state or action constraint changed, + * the spec has a VIEW or a SYMMETRY set, or anything explored reads {@code TLCGet} (whose + * values depend on the path to a state, not the state), nothing can be + * reused and the caller runs a fresh exploration instead; so does the caller + * when the model config changed or the old exploration did not finish. + * + *

+ * Otherwise the old store's graph is replayed: the states reachable from the + * initial states along edges of unchanged actions survive, each copied into + * a new store with those edges; surviving states are re-expanded under the + * changed and added actions only; states reached for the first time are + * expanded under every action. Every new state is checked against every + * invariant, and every surviving state against the changed invariants. + * Successors a constraint excludes are checked too, as TLC checks them, and + * kept in the store unexpanded; edges to them under unchanged actions are + * carried like any other. + * Successor generation, constraints and invariant evaluation all go through + * the new {@link Tool}, so the store is a cache of TLC's own answers, never + * an oracle of its own. Copying an edge is sound only because the old store + * holds a fully explored graph under the same constraints: every in-model + * successor of a stored state under an unchanged action is already an edge. + * + *

+ * Not covered here: liveness (the tableau is not rebuilt), deadlock + * reporting, and the blocked-guard tallies for the re-expanded states + * (successor generation here does not pass through the constrained writer). + */ +public final class Incremental { + + /** How an old action maps to the new spec. */ + public static final class ActionDiff { + public final List unchanged = new ArrayList<>(); + public final List changed = new ArrayList<>(); + public final List added = new ArrayList<>(); + public final List removed = new ArrayList<>(); + /** Old action id to the new action, for unchanged actions. */ + final Map carried = new HashMap<>(); + /** New actions to re-expand surviving states under. */ + final List reexpand = new ArrayList<>(); + public final List changedInvariants = new ArrayList<>(); + public final List changedDefinitions = new ArrayList<>(); + public String fullRerunReason; + } + + public static final class Violation { + public final String invariant; + public final long fp; + public final int level; + + Violation(String invariant, long fp, int level) { + this.invariant = invariant; + this.fp = fp; + this.level = level; + } + } + + public static final class Result { + public long survivors; + public long dropped; + public long reexpanded; + public long newStates; + public long edgesCopied; + public long edgesGenerated; + public boolean budgetExhausted; + public final List violations = new ArrayList<>(); + public String error; + /** + * Set when a stored state does not read back as the state it was + * stored as (its fingerprint under the edited spec differs): nothing + * copied from the old store can be trusted, so the caller restarts. + */ + public String restartReason; + } + + private Incremental() { + } + + private static String text(final Action a) { + return a.pred == null ? "" : GraphStore.text(a.pred); + } + + /** Pair the old and new specs' actions, invariants and definitions. */ + public static ActionDiff diff(final Tool oldTool, final Tool newTool) { + final ActionDiff d = new ActionDiff(); + // Variables, from each tool's own declarations: TLC's static variable + // table (behind the store's decoding) already belongs to the new parse. + if (!variableNames(oldTool).equals(variableNames(newTool))) { + d.fullRerunReason = "the variables changed"; + return d; + } + // Definitions of the root module, by name (reported, not decisive: + // the signatures below decide what changed). + final Map oldDefs = definitions(oldTool); + final Map newDefs = definitions(newTool); + for (final Map.Entry e : newDefs.entrySet()) { + final String before = oldDefs.get(e.getKey()); + if (before == null || !before.equals(e.getValue())) { + d.changedDefinitions.add(e.getKey()); + } + } + for (final String name : oldDefs.keySet()) { + if (!newDefs.containsKey(name)) { + d.changedDefinitions.add(name); + } + } + // What every explored state and edge was filtered or identified by. + if (!initSignature(oldTool).equals(initSignature(newTool))) { + d.fullRerunReason = "the initial predicate changed"; + return d; + } + if (!constraintSignature(oldTool).equals(constraintSignature(newTool))) { + d.fullRerunReason = "a state or action constraint changed"; + return d; + } + for (final Tool t : new Tool[] { oldTool, newTool }) { + final String reason = fingerprintAbstraction(t); + if (reason != null) { + d.fullRerunReason = reason; + return d; + } + } + if (!substitutionSignature(oldTool).equals(substitutionSignature(newTool))) { + d.fullRerunReason = "a definition the config substitutes with <- changed"; + return d; + } + for (final Tool t : new Tool[] { oldTool, newTool }) { + final String reason = tlcGetUse(t); + if (reason != null) { + d.fullRerunReason = reason; + return d; + } + } + // Actions. + final Map> oldByKey = new HashMap<>(); + final Map oldSig = new HashMap<>(); + for (final Action a : oldTool.getActions()) { + oldByKey.computeIfAbsent(a.getNameOfDefault(), k -> new ArrayList<>()).add(a); + oldSig.put(a.getId(), signature(a)); + } + // Exact (name, signature) pairs first, over every new action, so an + // edited action cannot claim an old one that a later new action + // matches exactly: the disjuncts of an unnamed Next all share its + // name, and inserting one in front would otherwise shift every pair. + final Set matchedOld = new HashSet<>(); + final Action[] newActions = newTool.getActions(); + final Action[] exact = new Action[newActions.length]; + for (int i = 0; i < newActions.length; i++) { + final String sig = signature(newActions[i]); + for (final Action o : oldByKey.getOrDefault(newActions[i].getNameOfDefault(), List.of())) { + if (!matchedOld.contains(o.getId()) && oldSig.get(o.getId()).equals(sig)) { + matchedOld.add(o.getId()); + exact[i] = o; + break; + } + } + } + // Then the rest: a same-named old action left over makes it changed. + for (int i = 0; i < newActions.length; i++) { + final Action n = newActions[i]; + final String name = n.getNameOfDefault(); + if (exact[i] != null) { + d.carried.put(exact[i].getId(), n); + d.unchanged.add(name); + } else if (pairUnmatched(oldByKey.getOrDefault(name, List.of()), matchedOld)) { + // Same name, different signature. + d.changed.add(name); + d.reexpand.add(n); + } else { + d.added.add(name); + d.reexpand.add(n); + } + } + for (final Action o : oldTool.getActions()) { + if (!matchedOld.contains(o.getId())) { + d.removed.add(o.getNameOfDefault()); + } + } + // Invariants. + final Map oldInv = new HashMap<>(); + final String[] oldNames = oldTool.getInvNames(); + final Action[] oldInvs = oldTool.getInvariants(); + for (int i = 0; i < oldInvs.length; i++) { + oldInv.put(i < oldNames.length ? oldNames[i] : oldInvs[i].getNameOfDefault(), signature(oldInvs[i])); + } + final String[] newNames = newTool.getInvNames(); + final Action[] newInvs = newTool.getInvariants(); + for (int i = 0; i < newInvs.length; i++) { + final String name = i < newNames.length ? newNames[i] : newInvs[i].getNameOfDefault(); + if (!signature(newInvs[i]).equals(oldInv.get(name))) { + d.changedInvariants.add(name); + } + } + return d; + } + + /** + * Why a replay cannot be trusted because {@code tool} reads TLC's + * registers, or null. {@code TLCGet("level")} and its siblings depend on + * how a state was reached, not only on its fingerprint: an added action + * can shorten the path to a state and bring successors a level-bounded + * constraint excluded back into the model, which copied edges never + * re-examine. Replay also evaluates without the predecessor TLC sets. + */ + private static String tlcGetUse(final Tool tool) { + for (final Action a : tool.getActions()) { + if (reachesTLCGet(a.pred)) { + return "the action " + a.getNameOfDefault() + " reads TLCGet, which depends on the path to a state"; + } + } + final Vect init = tool.getInitStateSpec(); + for (int i = 0; i < init.size(); i++) { + if (reachesTLCGet(init.elementAt(i).pred)) { + return "the initial predicate reads TLCGet, which depends on the path to a state"; + } + } + for (final ExprNode c : tool.getModelConstraints()) { + if (reachesTLCGet(c)) { + return "a state constraint reads TLCGet, which depends on the path to a state"; + } + } + for (final ExprNode c : tool.getActionConstraints()) { + if (reachesTLCGet(c)) { + return "an action constraint reads TLCGet, which depends on the path to a state"; + } + } + for (final Action a : tool.getInvariants()) { + if (reachesTLCGet(a.pred)) { + return "the invariant " + a.getNameOfDefault() + " reads TLCGet, which depends on the path to a state"; + } + } + // The definitions the config substitutes in are bound as tool + // objects, so the walks above never reach them (see + // substitutionSignature): check each one itself. + final Map byName = new HashMap<>(); + final OpDefNode[] defs = tool.getSpecProcessor().getRootModule().getOpDefs(); + for (final OpDefNode def : defs == null ? new OpDefNode[0] : defs) { + byName.put(def.getName().toString(), def); + } + final Map rhs = new TreeMap<>(tool.getModelConfig().getOverrides()); + final Map modOverrides = tool.getModelConfig().getModOverrides(); + for (final Map.Entry m : modOverrides.entrySet()) { + for (final Map.Entry e : ((Map) m.getValue()).entrySet()) { + rhs.put(m.getKey() + "!" + e.getKey(), String.valueOf(e.getValue())); + } + } + for (final Map.Entry e : rhs.entrySet()) { + final OpDefNode def = byName.get(e.getValue()); + if (def == null) { + continue; + } + final Map reached = new TreeMap<>(); + reachDefinition(def, reached, new HashSet<>()); + if (pathDependent(reached)) { + return "the config substitutes " + e.getKey() + " <- " + e.getValue() + + ", which reads TLCGet or the behaviour, which depend on the path to a state"; + } + } + return null; + } + + /** + * Operators whose value depends on how a state was reached: TLC's + * registers, and TLCExt's behaviour-so-far operators (overridden in Java + * to read the predecessor chain, which a replay does not set). + */ + private static final Set PATH_DEPENDENT = Set.of("TLC!TLCGet", "TLCExt!Trace", "TLCExt!CounterExample"); + + private static boolean pathDependent(final Map reached) { + for (final String name : PATH_DEPENDENT) { + if (reached.containsKey(name)) { + return true; + } + } + return false; + } + + private static boolean reachesTLCGet(final SemanticNode node) { + final Map reached = new TreeMap<>(); + reach(node, reached, new HashSet<>()); + return pathDependent(reached); + } + + /** Claim the first old action of {@code candidates} not yet paired; false when none is left. */ + private static boolean pairUnmatched(final List candidates, final Set matchedOld) { + for (final Action o : candidates) { + if (matchedOld.add(o.getId())) { + return true; + } + } + return false; + } + + private static List variableNames(final Tool tool) { + final List out = new ArrayList<>(); + for (final OpDeclNode v : tool.getSpecProcessor().getVariablesNodes()) { + out.add(v.getName().toString()); + } + return out; + } + + private static Map definitions(final Tool tool) { + final Map out = new HashMap<>(); + final OpDefNode[] defs = tool.getSpecProcessor().getRootModule().getOpDefs(); + if (defs == null) { + return out; + } + for (final OpDefNode def : defs) { + if (def.getBody() == null) { + continue; + } + out.put(def.getName().toString(), GraphStore.text(def.getBody())); + } + return out; + } + + private static String signature(final Action a) { + return signature(a.pred, a.con); + } + + /** + * The text of {@code node}, the name, formal parameters and text of every + * user definition it reaches (transitively, in name order), the + * substitutions of every INSTANCE it passes through and the values + * {@code con} binds. + * Two nodes with equal signatures denote the same predicate under the same + * constant values, which the config pins. + */ + private static String signature(final SemanticNode node, final Context con) { + final StringBuilder sb = new StringBuilder(GraphStore.text(node)); + final Map reached = new TreeMap<>(); + reach(node, reached, new HashSet<>()); + for (final Map.Entry e : reached.entrySet()) { + sb.append('\n').append(e.getKey()).append(" == ").append(e.getValue()); + } + if (con != null && con != Context.Empty) { + sb.append("\nwith ").append(con); + } + return sb.toString(); + } + + private static void reach(final SemanticNode node, final Map reached, + final Set seen) { + if (node == null || !seen.add(node)) { + return; + } + if (node instanceof OpApplNode) { + reachDefinition(((OpApplNode) node).getOperator(), reached, seen); + } else if (node instanceof OpArgNode) { + reachDefinition(((OpArgNode) node).getOp(), reached, seen); + } else if (node instanceof SubstInNode) { + // INSTANCE ... WITH a <- e: which parameter each expression + // replaces is not in any definition's text. + final StringBuilder with = new StringBuilder(); + for (final Subst s : ((SubstInNode) node).getSubsts()) { + with.append(s.getOp().getName()).append(" <- ").append(GraphStore.text(s.getExpr())).append(", "); + } + // Keyed by content, not location, so moving the text is no edit. + reached.put("WITH " + with, ""); + } + final SemanticNode[] children = node.getChildren(); + if (children != null) { + for (final SemanticNode c : children) { + reach(c, reached, seen); + } + } + } + + private static void reachDefinition(final SymbolNode op, final Map reached, + final Set seen) { + if (!(op instanceof OpDefNode)) { + return; + } + final OpDefNode def = (OpDefNode) op; + if (def.getKind() != ASTConstants.UserDefinedOpKind || def.getBody() == null) { + return; + } + // Keyed by module too: two modules may define the same name. + final String module = def.getLocation() == null ? "" : def.getLocation().source() + "!"; + // The formal parameters too: F(a, b) == a - b and F(b, a) == a - b + // share their body text. + final StringBuilder params = new StringBuilder("("); + for (final FormalParamNode p : def.getParams()) { + params.append(p.getName()).append('/').append(p.getArity()).append(", "); + } + reached.put(module + def.getName(), params.append(") ").append(GraphStore.text(def.getBody())).toString()); + reach(def.getBody(), reached, seen); + } + + private static String initSignature(final Tool tool) { + final StringBuilder sb = new StringBuilder(); + final Vect init = tool.getInitStateSpec(); + for (int i = 0; i < init.size(); i++) { + sb.append(signature(init.elementAt(i))).append('\n'); + } + return sb.toString(); + } + + private static String constraintSignature(final Tool tool) { + final StringBuilder sb = new StringBuilder(); + for (final ExprNode c : tool.getModelConstraints()) { + sb.append("state ").append(signature(c, null)).append('\n'); + } + for (final ExprNode c : tool.getActionConstraints()) { + sb.append("action ").append(signature(c, null)).append('\n'); + } + return sb.toString(); + } + + /** + * The definitions the config substitutes in ({@code CONSTANT N <- Def}, + * {@code Op <- Def}, {@code Op <- [M] Def}). TLC binds them as tool + * objects, so the syntactic walk in {@link #signature} never reaches them: + * an edit to {@code Def} would leave every action's signature unchanged. + */ + private static String substitutionSignature(final Tool tool) { + final Map byName = new HashMap<>(); + final OpDefNode[] defs = tool.getSpecProcessor().getRootModule().getOpDefs(); + for (final OpDefNode def : defs == null ? new OpDefNode[0] : defs) { + byName.put(def.getName().toString(), def); + } + final Map subst = new TreeMap<>(); + final Map overrides = tool.getModelConfig().getOverrides(); + for (final Map.Entry e : overrides.entrySet()) { + subst.put(e.getKey(), substituted(e.getValue(), byName)); + } + final Map modOverrides = tool.getModelConfig().getModOverrides(); + for (final Map.Entry m : modOverrides.entrySet()) { + for (final Map.Entry e : ((Map) m.getValue()).entrySet()) { + subst.put(m.getKey() + "!" + e.getKey(), substituted(String.valueOf(e.getValue()), byName)); + } + } + final StringBuilder sb = new StringBuilder(); + for (final Map.Entry e : subst.entrySet()) { + sb.append(e.getKey()).append(" <- ").append(e.getValue()).append('\n'); + } + return sb.toString(); + } + + private static String substituted(final String rhs, final Map byName) { + final OpDefNode def = byName.get(rhs); + return def == null || def.getBody() == null ? rhs : rhs + " == " + signature(def.getBody(), null); + } + + /** + * Why a replay cannot be trusted because {@code tool} fingerprints states + * through a VIEW or a SYMMETRY set, or null. A fingerprint then names + * several concrete states and TLC explores the one that reached it first. + * The store keeps that one's content, which a copied edge from another + * state need not generate, so a replay can explore a representative the + * edited spec never reaches. + */ + private static String fingerprintAbstraction(final Tool tool) { + if (tool.getViewSpec() != null) { + return "the spec has a VIEW, under which a stored state may not be the one a copied edge reaches"; + } + final String symmetry = tool.getModelConfig().getSymmetry(); + if (tool.getSymmetryPerms() != null || (symmetry != null && !symmetry.isEmpty())) { + return "the spec has a SYMMETRY set, under which a stored state may not be the one a copied edge reaches"; + } + return null; + } + + /** + * Replay the old graph into {@code newStore} under {@code newTool}, + * re-expanding only where the edit reaches. Stops at the first violation + * unless {@code continueOnViolation}, or when the budget runs out. + */ + public static Result replay(final Tool newTool, final GraphStore oldStore, final GraphStore newStore, + final ActionDiff diff, final long budgetMs, final boolean continueOnViolation) { + final Result r = new Result(); + final long started = System.currentTimeMillis(); + final Action[] invariants = newTool.getInvariants(); + final String[] invNames = newTool.getInvNames(); + final Set changedInv = new HashSet<>(diff.changedInvariants); + // Forward adjacency of the old graph restricted to carried actions: + // fp -> (succ fp, new action) pairs. + final Map> forward = new HashMap<>(); + for (final long fp : oldStore.fingerprints()) { + for (final long[] p : oldStore.predecessorsOf(fp)) { + final Action carried = diff.carried.get((int) p[1]); + if (carried == null) { + continue; + } + forward.computeIfAbsent(p[0], k -> new ArrayList<>()).add(new long[] { fp, carried.getId() }); + } + } + final ArrayDeque queue = new ArrayDeque<>(); + final Set seen = new HashSet<>(); + // Initial states are unchanged (the init predicate is), so they seed. + for (final long fp : oldStore.initialFingerprints()) { + final TLCState s = carry(newTool, oldStore, fp, r); + if (r.restartReason != null) { + return r; + } + if (s == null) { + continue; + } + newStore.writeState(s); + seen.add(fp); + queue.add(fp); + } + final Set oldStates = new HashSet<>(); + for (final long fp : oldStore.fingerprints()) { + oldStates.add(fp); + } + boolean stop = false; + // Initial states the (unchanged) constraints exclude: never explored, + // but TLC checks invariants on them, so the changed ones are checked. + // All are stored before any is checked, so the state-level properties + // see every initial state however the replay stops. + final List excludedInitial = new ArrayList<>(); + for (final long fp : oldStore.excludedInitialFingerprints()) { + final TLCState s = carry(newTool, oldStore, fp, r); + if (r.restartReason != null) { + return r; + } + if (s != null) { + newStore.writeExcludedInitial(s); + excludedInitial.add(s); + } + } + for (final TLCState s : excludedInitial) { + if (checkInvariants(newTool, s, s.fingerPrint(), 1, invariants, invNames, changedInv, continueOnViolation, r)) { + stop = true; + break; + } + } + while (!queue.isEmpty() && !stop) { + if (System.currentTimeMillis() - started > budgetMs) { + r.budgetExhausted = true; + break; + } + final long fp = queue.poll(); + final TLCState state = rebind(newTool, newStore.read(fp)); + // No VIEW or SYMMETRY (see fingerprintAbstraction): a fingerprint + // names one state, so a stored one keeps its old edges. + final boolean survivor = oldStates.contains(fp); + if (survivor) { + r.survivors++; + // Carried edges: copy successors and their content. + try { + for (final long[] e : forward.getOrDefault(fp, List.of())) { + final long to = e[0]; + final Action a = newTool.getActions()[actionIndex(newTool, (int) e[1])]; + final TLCState succ = newStore.contains(to) ? newStore.read(to) + : carry(newTool, oldStore, to, r); + if (r.restartReason != null) { + return r; + } + if (succ == null) { + continue; + } + final boolean unseen = !newStore.contains(to); + newStore.writeState(state, succ, + unseen ? tlc2.util.IStateWriter.IsUnseen : tlc2.util.IStateWriter.IsSeen, a); + r.edgesCopied++; + if (seen.add(to)) { + queue.add(to); + } + } + } catch (final Throwable t) { + r.error = "copying edges: " + t; + break; + } + // Changed invariants on a survivor. + if (checkInvariants(newTool, state, fp, newStore.level(fp), invariants, invNames, changedInv, + continueOnViolation, r)) { + break; + } + // Carried edges to excluded successors: the same successors under + // the same constraints, so still excluded. TLC checked every + // invariant on them; the changed ones are checked again. + try { + for (final long[] e : oldStore.excludedSuccessors(fp)) { + final Action a = diff.carried.get((int) e[1]); + if (a == null) { + continue; + } + final long to = e[0]; + final boolean fresh = !newStore.contains(to) && !newStore.isExcluded(to); + final TLCState succ = fresh ? carry(newTool, oldStore, to, r) : newStore.read(to); + if (r.restartReason != null) { + return r; + } + if (succ == null) { + continue; + } + newStore.writeExcluded(state, succ, a); + r.edgesCopied++; + if (fresh && checkInvariants(newTool, succ, to, newStore.level(to), invariants, invNames, + changedInv, continueOnViolation, r)) { + stop = true; + break; + } + } + } catch (final Throwable t) { + r.error = "copying excluded edges: " + t; + break; + } + if (stop) { + break; + } + if (!diff.reexpand.isEmpty()) { + r.reexpanded++; + } + stop = expand(newTool, newStore, state, fp, diff.reexpand, invariants, invNames, continueOnViolation, + seen, queue, r); + } else { + r.newStates++; + stop = expand(newTool, newStore, state, fp, List.of(newTool.getActions()), invariants, invNames, + continueOnViolation, seen, queue, r); + } + } + r.dropped = oldStates.size() - r.survivors; + return r; + } + + private static int actionIndex(final Tool tool, final int id) { + final Action[] actions = tool.getActions(); + for (int i = 0; i < actions.length; i++) { + if (actions[i].getId() == id) { + return i; + } + } + throw new IllegalStateException("no action with id " + id); + } + + /** + * The old store's state {@code fp} rebuilt against the new spec, or null + * when it is not stored. Copying it is sound only if it is the same state: + * when its fingerprint under the new spec differs (a value that decodes + * differently now, such as a model value renumbered by the parse), sets + * {@link Result#restartReason} and returns null. + */ + private static TLCState carry(final Tool tool, final GraphStore oldStore, final long fp, final Result r) { + final TLCState s; + final long now; + try { + s = rebind(tool, oldStore.read(fp)); + if (s == null) { + return null; + } + now = s.fingerPrint(); + } catch (final RuntimeException e) { + r.restartReason = "a stored state does not decode under the edited spec (" + e + "), so the old graph cannot be carried"; + return null; + } + if (now != fp) { + r.restartReason = "a stored state does not read back as the same state under the edited spec (its fingerprint changed), so the old graph cannot be carried"; + return null; + } + return s; + } + + /** A stored state rebuilt against the new spec's variable set. */ + private static TLCState rebind(final Tool tool, final TLCState s) { + if (s == null) { + return null; + } + TLCState out = TLCState.Empty.createEmpty(); + for (final tla2sany.semantic.OpDeclNode v : out.getVars()) { + final tlc2.value.IValue value = s.lookup(v.getName()); + if (value != null) { + out = out.bind(v.getName(), value); + } + } + return out; + } + + /** + * Expand one state under {@code actions}; returns true to stop. As TLC's + * worker does, every invariant is checked on a successor seen for the + * first time, including one a constraint excludes from the model: the + * excluded one is kept in the store, not expanded. + */ + private static boolean expand(final Tool tool, final GraphStore store, final TLCState state, final long fp, + final List actions, final Action[] invariants, final String[] invNames, + final boolean continueOnViolation, final Set seen, final ArrayDeque queue, final Result r) { + for (final Action a : actions) { + final StateVec next; + try { + next = tool.getNextStatesUnrecorded(a, state); + } catch (final Throwable t) { + r.error = a.getNameOfDefault() + ": " + t; + return true; + } + for (int i = 0; i < next.size(); i++) { + final TLCState succ = next.elementAt(i); + if (!succ.allAssigned()) { + r.error = a.getNameOfDefault() + " left " + succ.getUnassigned() + " unassigned"; + return true; + } + boolean inModel; + try { + inModel = inModel(tool, state, succ); + } catch (final Throwable t) { + r.error = "constraint: " + t; + return true; + } + final long to = succ.fingerPrint(); + if (!inModel) { + final boolean fresh = !store.contains(to) && !store.isExcluded(to); + store.writeExcluded(state, succ, a); + if (fresh && checkInvariants(tool, succ, to, store.level(to), invariants, invNames, null, + continueOnViolation, r)) { + return true; + } + continue; + } + final boolean unseen = !store.contains(to); + store.writeState(state, succ, unseen ? tlc2.util.IStateWriter.IsUnseen : tlc2.util.IStateWriter.IsSeen, a); + r.edgesGenerated++; + if (unseen && checkInvariants(tool, succ, to, store.level(to), invariants, invNames, null, + continueOnViolation, r)) { + return true; + } + if (seen.add(to)) { + queue.add(to); + } + } + } + return false; + } + + /** + * Whether {@code succ}, reached from {@code state}, satisfies every state + * and action constraint, as {@link Tool#isInModel(TLCState)} and + * {@link Tool#isInActions(TLCState, TLCState)} decide it. Those read the + * constraints' cost models, which only a tool a checker ran has, so a + * refreshed tool evaluates them without one. + */ + private static boolean inModel(final Tool tool, final TLCState state, final TLCState succ) { + for (final ExprNode c : tool.getModelConstraints()) { + if (!bool(tool.eval(c, Context.Empty, succ, tlc2.tool.coverage.CostModel.DO_NOT_RECORD), c)) { + return false; + } + } + for (final ExprNode c : tool.getActionConstraints()) { + if (!bool(tool.eval(c, Context.Empty, state, succ, tlc2.tool.EvalControl.Clear, + tlc2.tool.coverage.CostModel.DO_NOT_RECORD), c)) { + return false; + } + } + return true; + } + + private static boolean bool(final tlc2.value.IValue v, final ExprNode constraint) { + if (!(v instanceof tlc2.value.impl.BoolValue)) { + throw new IllegalStateException("constraint " + GraphStore.text(constraint) + " is not a boolean: " + v); + } + return ((tlc2.value.impl.BoolValue) v).val; + } + + /** + * Check the invariants named in {@code only} (every one when null) on + * {@code state}, recording violations; returns true to stop, on an error + * or on a violation unless {@code continueOnViolation}. + */ + private static boolean checkInvariants(final Tool tool, final TLCState state, final long fp, final Integer level, + final Action[] invariants, final String[] invNames, final Set only, + final boolean continueOnViolation, final Result r) { + for (int k = 0; k < invariants.length; k++) { + final String name = k < invNames.length ? invNames[k] : invariants[k].getNameOfDefault(); + if (only != null && !only.contains(name)) { + continue; + } + boolean holds; + try { + holds = holds(tool, invariants[k], state); + } catch (final Throwable t) { + r.error = name + ": " + t; + return true; + } + if (!holds) { + r.violations.add(new Violation(name, fp, level == null ? -1 : level)); + if (!continueOnViolation) { + return true; + } + } + } + return false; + } + + /** + * Whether the state predicate {@code inv} holds in {@code state}, as + * {@link Tool#isValid(Action, TLCState)} decides it but without counting + * the evaluation in the invariant's coverage: a sweep over the store is + * not part of the run whose coverage is reported. + */ + static boolean holds(final Tool tool, final Action inv, final TLCState state) { + final tlc2.value.IValue v = tool.eval(inv.pred, inv.con, state, tlc2.tool.coverage.CostModel.DO_NOT_RECORD); + if (!(v instanceof tlc2.value.impl.BoolValue)) { + throw new IllegalStateException("invariant " + inv.getNameOfDefault() + " is not a boolean: " + v); + } + return ((tlc2.value.impl.BoolValue) v).val; + } + + /** One invariant's verdict over a whole store. */ + public static final class Sweep { + public final String invariant; + public long violations; + public Long firstFp; + public Integer firstLevel; + public String error; + + Sweep(String invariant) { + this.invariant = invariant; + } + } + + /** + * Evaluate every invariant on every stored state, excluded successors and + * excluded initial states included, as TLC evaluates them: exact per-invariant + * verdicts for the refreshed graph, the first violation being the one at + * the lowest level. Costs one evaluation per (state, invariant), no + * successor generation. + */ + public static List sweep(final Tool tool, final GraphStore store) { + return sweep(tool, store, null); + } + + /** + * {@link #sweep(Tool, GraphStore)} over the invariants named in + * {@code only} (every invariant when null); one {@link Sweep} per + * configured invariant, in order, with those left out untouched. + */ + public static List sweep(final Tool tool, final GraphStore store, final Set only) { + final Action[] invariants = tool.getInvariants(); + final String[] names = tool.getInvNames(); + final List out = new ArrayList<>(); + final boolean[] wanted = new boolean[invariants.length]; + for (int k = 0; k < invariants.length; k++) { + final String name = k < names.length ? names[k] : invariants[k].getNameOfDefault(); + out.add(new Sweep(name)); + wanted[k] = only == null || only.contains(name); + } + // The excluded successors too: TLC checks invariants on them. + final long[] inModel = store.fingerprints(); + final long[] excluded = store.excludedFingerprints(); + final long[] all = java.util.Arrays.copyOf(inModel, inModel.length + excluded.length); + System.arraycopy(excluded, 0, all, inModel.length, excluded.length); + for (final long fp : all) { + final TLCState state = rebind(tool, store.read(fp)); + if (state == null) { + continue; + } + final Integer level = store.level(fp); + for (int k = 0; k < invariants.length; k++) { + final Sweep sw = out.get(k); + if (!wanted[k] || sw.error != null) { + continue; + } + try { + if (!holds(tool, invariants[k], state)) { + sw.violations++; + if (sw.firstLevel == null || (level != null && level < sw.firstLevel)) { + sw.firstLevel = level; + sw.firstFp = fp; + } + } + } catch (final Throwable t) { + sw.error = t.getMessage() == null ? t.toString() : t.getMessage(); + } + } + } + return out; + } + + /** + * Evaluate every state-level PROPERTY (TLC's implied inits, which it + * checks on the initial states only) on the stored initial states, those a + * state constraint excluded included: one + * {@link Sweep} per implied init, in order. + */ + public static List impliedInits(final Tool tool, final GraphStore store) { + final Action[] inits = tool.getImpliedInits(); + final String[] names = tool.getImpliedInitNames(); + final List out = new ArrayList<>(); + for (int k = 0; k < inits.length; k++) { + out.add(new Sweep(k < names.length ? names[k] : inits[k].getNameOfDefault())); + } + final long[] inModel = store.initialFingerprints(); + final long[] excluded = store.excludedInitialFingerprints(); + final long[] all = java.util.Arrays.copyOf(inModel, inModel.length + excluded.length); + System.arraycopy(excluded, 0, all, inModel.length, excluded.length); + for (final long fp : all) { + final TLCState state = rebind(tool, store.read(fp)); + if (state == null) { + continue; + } + for (int k = 0; k < inits.length; k++) { + final Sweep sw = out.get(k); + if (sw.error != null) { + continue; + } + try { + if (!holds(tool, inits[k], state)) { + sw.violations++; + if (sw.firstFp == null) { + sw.firstFp = fp; + sw.firstLevel = 1; + } + } + } catch (final Throwable t) { + sw.error = t.getMessage() == null ? t.toString() : t.getMessage(); + } + } + } + return out; + } + + public static JsonObject diffJson(final ActionDiff d) { + final JsonObject o = new JsonObject(); + o.add("unchanged", names(d.unchanged)); + o.add("changed", names(d.changed)); + o.add("added", names(d.added)); + o.add("removed", names(d.removed)); + o.add("changed_invariants", names(d.changedInvariants)); + o.add("changed_definitions", names(d.changedDefinitions)); + if (d.fullRerunReason != null) { + o.addProperty("full_rerun_reason", d.fullRerunReason); + } + return o; + } + + private static JsonArray names(final List list) { + final JsonArray a = new JsonArray(); + for (final String s : list) { + a.add(s); + } + return a; + } +} diff --git a/tlatools/org.lamport.tlatools/src/tlc2/basis/Recorder.java b/tlatools/org.lamport.tlatools/src/tlc2/basis/Recorder.java new file mode 100644 index 0000000000..0cfc375586 --- /dev/null +++ b/tlatools/org.lamport.tlatools/src/tlc2/basis/Recorder.java @@ -0,0 +1,416 @@ +/******************************************************************************* + * Copyright (c) 2026 Basis Research Institute. All rights reserved. + * + * The MIT License (MIT) + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies + * of the Software, and to permit persons to whom the Software is furnished to do + * so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + ******************************************************************************/ +package tlc2.basis; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import com.google.gson.JsonPrimitive; + +import tlc2.module.Json; +import tlc2.output.EC; +import tlc2.output.IMessagePrinterRecorder; +import tlc2.tool.TLCState; +import tlc2.tool.TLCStateInfo; +import tlc2.value.IValue; +import util.UniqueString; + +/** + * Keeps what TLC reports, typed, instead of printing it. + * + * TLC's {@link tlc2.output.MP} hands every message to the registered + * recorder as its error code plus the objects the message was built from, + * before rendering them to text. Counterexample states arrive as + * {@link TLCStateInfo} objects, so a trace can be handed on as values rather + * than re-parsed out of the printed form. Everything else is kept as the + * code and its parameters' string forms, for the artifact. + */ +public final class Recorder implements IMessagePrinterRecorder { + + /** A counterexample being assembled from TLC_STATE_PRINT2 messages. */ + public static final class Trace { + /** Why TLC printed a trace: the code that opened it. */ + public int code; + /** The invariant or property named by that code, when it names one. */ + public String property; + public final List states = new ArrayList<>(); + /** True once TLC said the trace ends in stuttering. */ + public boolean stuttering; + /** The ordinal the lasso loops back to, or null. */ + public Integer lassoTo; + /** + * For a violation in an initial state: the state as TLC printed it + * into the report. TLC prints no trace for such a violation, so the + * one-state trace is completed from this by {@link Recorder#completeInitial}. + */ + public String initialText; + } + + /** Whether a code reports a violation in an initial state, which TLC prints no trace for. */ + private static boolean isInitialViolation(final int code) { + return code == EC.TLC_INVARIANT_VIOLATED_INITIAL || code == EC.TLC_PROPERTY_VIOLATED_INITIAL; + } + + /** + * Whether a code opens a violation report, whose behaviour TLC prints + * once. An evaluation failure's behaviour may be printed twice (the rerun + * that rebuilds its call stack). + */ + private static boolean isViolationReport(final int code) { + switch (code) { + case EC.TLC_INVARIANT_VIOLATED_INITIAL: + case EC.TLC_INVARIANT_VIOLATED_BEHAVIOR: + case EC.TLC_INVARIANT_VIOLATED_LEVEL: + case EC.TLC_ACTION_PROPERTY_VIOLATED_BEHAVIOR: + case EC.TLC_TEMPORAL_PROPERTY_VIOLATED: + case EC.TLC_PROPERTY_VIOLATED_INITIAL: + case EC.TLC_DEADLOCK_REACHED: + return true; + default: + return false; + } + } + + private final List messages = new ArrayList<>(); + private Trace trace; + private Trace finishedTrace; + /** Every counterexample TLC printed, in order (several under continuation). */ + private final List traces = new ArrayList<>(); + /** How often each property was reported violated. */ + private final java.util.LinkedHashMap violationCounts = new java.util.LinkedHashMap<>(); + /** Reports past the per-property trace cap, per property: counted, not kept as messages. */ + private final java.util.LinkedHashMap untraced = new java.util.LinkedHashMap<>(); + /** Invariants whose evaluation failed, to the first failure's message. */ + private final java.util.LinkedHashMap evaluationFailures = new java.util.LinkedHashMap<>(); + private JsonObject finalStats; + private int outcome = EC.NO_ERROR; + private String outcomeProperty; + + @Override + public synchronized void record(final int code, final Object... objects) { + if ((code == EC.TLC_INVARIANT_VIOLATED_BEHAVIOR || code == EC.TLC_ACTION_PROPERTY_VIOLATED_BEHAVIOR) + && objects != null && objects.length > 0) { + final String property = String.valueOf(objects[0]); + if (tlc2.TLCGlobals.continuationTraceCapped(property)) { + // Past the per-property trace cap under continuation: no trace + // follows, so the report is counted, not kept. A run whose + // invariant fails on most states would otherwise keep a + // message and an empty trace per state. + if (outcome == EC.NO_ERROR) { + outcome = code; + outcomeProperty = property; + } + violationCounts.merge(property, 1, Integer::sum); + untraced.merge(property, 1L, Long::sum); + trace = null; + return; + } + } + final JsonObject message = new JsonObject(); + message.addProperty("code", code); + final JsonArray params = new JsonArray(); + if (objects != null) { + for (final Object o : objects) { + if (o instanceof TLCStateInfo) { + // A stuttering tail (TLC_STATE_PRINT3) comes with a null state. + final TLCStateInfo info = (TLCStateInfo) o; + params.add(info.state == null ? JsonParser.parseString("null") : stateInfo(info)); + } else if (o instanceof TLCState) { + params.add(state((TLCState) o)); + } else if (o instanceof Object[]) { + final JsonArray inner = new JsonArray(); + for (final Object i : (Object[]) o) { + inner.add(String.valueOf(i)); + } + params.add(inner); + } else { + params.add(String.valueOf(o)); + } + } + } + message.add("params", params); + messages.add(message); + + switch (code) { + case EC.TLC_INVARIANT_VIOLATED_INITIAL: + case EC.TLC_INVARIANT_VIOLATED_BEHAVIOR: + case EC.TLC_INVARIANT_VIOLATED_LEVEL: + case EC.TLC_ACTION_PROPERTY_VIOLATED_BEHAVIOR: + case EC.TLC_TEMPORAL_PROPERTY_VIOLATED: + case EC.TLC_PROPERTY_VIOLATED_INITIAL: + case EC.TLC_DEADLOCK_REACHED: + case EC.TLC_INVARIANT_EVALUATION_FAILED: + final String property = objects != null && objects.length > 0 && !(objects[0] instanceof TLCState) + && !(objects[0] instanceof TLCStateInfo) + ? String.valueOf(objects[0]) + : null; + if (outcome == EC.NO_ERROR) { + outcome = code; + outcomeProperty = property; + } + if (code == EC.TLC_INVARIANT_EVALUATION_FAILED) { + // The invariant did not evaluate: that is no verdict on it, so + // it is kept apart from the violations. + evaluationFailures.putIfAbsent(property == null ? "" : property, + objects != null && objects.length > 1 ? String.valueOf(objects[1]) : ""); + } else { + violationCounts.merge(property == null ? "" : property, 1, Integer::sum); + } + trace = new Trace(); + trace.code = code; + trace.property = property; + if (isInitialViolation(code) && objects != null && objects.length > 1) { + trace.initialText = String.valueOf(objects[1]); + } + traces.add(trace); + break; + case EC.TLC_BEHAVIOR_UP_TO_THIS_POINT: + // The behaviour is printed from its first state on. When TLC prints + // it again for the same report (an evaluation error re-run to + // rebuild its call stack), the reprint replaces what came before. + // A violation's trace is printed once: a behaviour printed after it + // belongs to a later report that opened with no code of its own (a + // next-state evaluation error under continuation), so it starts a + // trace of its own rather than overwriting the violation's. + if (trace != null && !trace.states.isEmpty() && isViolationReport(trace.code)) { + trace = new Trace(); + trace.code = code; + } else if (trace != null && !trace.states.isEmpty()) { + trace.states.clear(); + trace.stuttering = false; + trace.lassoTo = null; + } + break; + case EC.TLC_STATE_PRINT1: + // A single state (an initial-state violation): no ordinal. + if (trace == null) { + trace = new Trace(); + trace.code = code; + } + // MP.printState wraps the state in a TLCStateInfo; a bare TLCState + // is kept too, for any caller that records it directly. + final JsonObject single = objects == null || objects.length == 0 ? null + : objects[0] instanceof TLCStateInfo && ((TLCStateInfo) objects[0]).state != null + ? stateInfo((TLCStateInfo) objects[0]) + : objects[0] instanceof TLCState ? state((TLCState) objects[0]) : null; + if (single != null) { + single.addProperty("ordinal", trace.states.size() + 1); + trace.states.add(single); + } + finishedTrace = trace; + break; + case EC.TLC_STATE_PRINT2: + if (trace == null) { + trace = new Trace(); + trace.code = code; + } + if (objects != null && objects.length >= 2 && objects[0] instanceof TLCStateInfo + && ((TLCStateInfo) objects[0]).state != null) { + final JsonObject s = stateInfo((TLCStateInfo) objects[0]); + s.addProperty("ordinal", objects[1] instanceof Integer ? (Integer) objects[1] : trace.states.size() + 1); + trace.states.add(s); + } + finishedTrace = trace; + break; + case EC.TLC_STATE_PRINT3: + if (trace != null) { + trace.stuttering = true; + finishedTrace = trace; + } + break; + case EC.TLC_BACK_TO_STATE: + if (trace != null && objects != null && objects.length >= 1) { + try { + trace.lassoTo = objects[0] instanceof TLCStateInfo && objects.length >= 2 + ? (Integer) objects[1] + : Integer.parseInt(String.valueOf(objects[0])); + } catch (final NumberFormatException e) { + trace.lassoTo = null; + } + finishedTrace = trace; + } + break; + case EC.TLC_STATS: + if (objects != null && objects.length >= 3) { + finalStats = new JsonObject(); + finalStats.addProperty("generated", parseLong(objects[0])); + finalStats.addProperty("distinct", parseLong(objects[1])); + finalStats.addProperty("queue", parseLong(objects[2])); + } + break; + default: + break; + } + } + + private static Long parseLong(final Object o) { + try { + return Long.parseLong(String.valueOf(o).replace(",", "")); + } catch (final NumberFormatException e) { + return null; + } + } + + /** Forget every counterexample and verdict, for a run that starts over. */ + public synchronized void reset() { + messages.clear(); + trace = null; + finishedTrace = null; + traces.clear(); + violationCounts.clear(); + untraced.clear(); + evaluationFailures.clear(); + finalStats = null; + outcome = EC.NO_ERROR; + outcomeProperty = null; + } + + /** The messages recorded so far, oldest first, and forget them. */ + public synchronized JsonArray drainMessages() { + final JsonArray out = new JsonArray(); + for (final JsonObject m : messages) { + out.add(m); + } + messages.clear(); + return out; + } + + /** + * Give every violation in an initial state its one-state trace. TLC prints + * the state only into the report's text, so {@code resolve} maps that text + * to the state as a typed value ({@link #state}), or returns null, and the + * state is then kept as TLC printed it, under {@code "tla"}. Such a trace + * becomes the last complete counterexample when no other has been printed. + */ + public synchronized void completeInitial(final java.util.function.Function resolve) { + for (final Trace t : traces) { + if (!isInitialViolation(t.code) || !t.states.isEmpty() || t.initialText == null) { + continue; + } + JsonObject s = resolve == null ? null : resolve.apply(t.initialText); + if (s == null) { + s = new JsonObject(); + s.addProperty("tla", t.initialText); + } + s.addProperty("action", ""); + s.addProperty("ordinal", 1); + t.states.add(s); + if (finishedTrace == null) { + finishedTrace = t; + } + } + } + + /** The last complete counterexample, or null. */ + public synchronized Trace trace() { + return finishedTrace; + } + + /** Every counterexample TLC printed so far, oldest first. */ + public synchronized List traces() { + return new ArrayList<>(traces); + } + + /** Property name to the number of times TLC reported it violated. */ + public synchronized Map violationCounts() { + return new java.util.LinkedHashMap<>(violationCounts); + } + + /** Invariant name to the message of its first failed evaluation. */ + public synchronized Map evaluationFailures() { + return new java.util.LinkedHashMap<>(evaluationFailures); + } + + /** Property name to the reports past its trace cap, which carry no message or trace. */ + public synchronized JsonObject untracedReports() { + final JsonObject out = new JsonObject(); + untraced.forEach(out::addProperty); + return out; + } + + /** The `TLC_STATS` line TLC prints at the end of a run, or null. */ + public synchronized JsonObject finalStats() { + return finalStats; + } + + /** The first violation code, or {@link EC#NO_ERROR}. */ + public synchronized int outcome() { + return outcome; + } + + public synchronized String outcomeProperty() { + return outcomeProperty; + } + + /** Render a trace state with its action and the value of every variable. */ + public static JsonObject stateInfo(final TLCStateInfo info) { + final JsonObject s = state(info.state); + s.addProperty("action", String.valueOf(info.info)); + if (info.fp != null) { + s.addProperty("fp", info.fp); + } + return s; + } + + /** + * A state as `{"fp": ..., "vars": {name: value}}`. Values go through the + * Json module's encoder (records, tuples and sequences become objects and + * arrays); a value it cannot encode keeps its TLA+ printed form under + * `"tla"`. + */ + public static JsonObject state(final TLCState state) { + final JsonObject s = new JsonObject(); + try { + s.addProperty("fp", state.fingerPrint()); + } catch (final RuntimeException e) { + // A state with unassigned variables has no fingerprint. + } + final JsonObject vars = new JsonObject(); + final Map vals = state.getVals(); + if (vals != null) { + for (final Map.Entry e : vals.entrySet()) { + vars.add(e.getKey().toString(), value(e.getValue())); + } + } + s.add("vars", vars); + return s; + } + + public static JsonElement value(final IValue value) { + if (value == null) { + return JsonParser.parseString("null"); + } + try { + return JsonParser.parseString(Json.toJson(value).val.toString()); + } catch (final Exception e) { + final JsonObject o = new JsonObject(); + o.add("tla", new JsonPrimitive(value.toString())); + return o; + } + } +} diff --git a/tlatools/org.lamport.tlatools/src/tlc2/basis/Resident.java b/tlatools/org.lamport.tlatools/src/tlc2/basis/Resident.java new file mode 100644 index 0000000000..3d2f364f97 --- /dev/null +++ b/tlatools/org.lamport.tlatools/src/tlc2/basis/Resident.java @@ -0,0 +1,1915 @@ +/******************************************************************************* + * Copyright (c) 2026 Basis Research Institute. All rights reserved. + * + * The MIT License (MIT) + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies + * of the Software, and to permit persons to whom the Software is furnished to do + * so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + ******************************************************************************/ +package tlc2.basis; + +import java.io.BufferedReader; +import java.io.File; +import java.io.FileDescriptor; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStreamReader; +import java.io.PrintStream; +import java.nio.charset.StandardCharsets; +import java.util.Date; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; + +import tlc2.TLCGlobals; +import tlc2.output.EC; +import tlc2.output.MP; +import tlc2.tool.Action; +import tlc2.tool.ModelChecker; +import tlc2.tool.TLCState; +import tlc2.tool.fp.FPSetConfiguration; +import tlc2.tool.fp.FPSetFactory; +import tlc2.util.FP64; +import tlc2.tool.impl.FastTool; +import tlc2.tool.impl.Tool; +import tlc2.tool.EvalControl; +import tlc2.tool.StateVec; +import tlc2.tool.coverage.CostModel; +import tlc2.util.Context; +import tla2sany.semantic.OpDefNode; +import tlc2.debug.TLCDebuggerExpression; +import tlc2.value.IValue; +import tlc2.value.impl.BoolValue; +import util.FileUtil; +import util.SimpleFilenameToStream; +import util.ToolIO; + +/** + * TLC as a resident service: one JVM, one parsed spec, one state graph, kept + * alive between requests and driven over JSON lines. + * + *

+ * Each request is one JSON object on a line of standard input; each reply is + * one JSON object on a line of standard output. Everything TLC would print + * goes to standard error instead, so the protocol owns stdout. Requests: + * + *

    + *
  • {@code open}: parse {@code spec} (a .tla path) with {@code config} (a + * .cfg path, default the spec's), build the checker, and answer with the + * catalogue: actions, invariants, implied actions, temporal properties and + * variables. Nothing is explored yet. {@code workers} sets the thread count, + * {@code metadir} where TLC keeps its state files, {@code deadlock} (default + * true) whether deadlocks are violations, {@code store} (default true) + * whether to keep the {@link GraphStore} the store queries and refresh + * need (it costs heap per state and edge, and time: every edge is + * recorded under one lock).
  • + *
  • {@code check}: explore, resuming where the last check stopped, until + * the reachable graph is exhausted, a violation is found, or the budget runs + * out: {@code budget_ms} of wall time, {@code budget_states} distinct + * states. The reply carries {@code finished}, the {@code verdict}, the + * counterexample {@code trace} when there is one, the statistics, and the + * typed messages TLC produced during the call.
  • + *
  • {@code stats}: the counters, without exploring.
  • + *
  • {@code close}: stop, and exit the process.
  • + *
+ * + *

+ * TLC is built around static state ({@link TLCGlobals}, {@link MP}), so one + * process serves one spec: a second {@code open} is refused. + */ +public final class Resident { + + private final Recorder recorder = new Recorder(); + private Tool tool; + /** + * The tool the checker (or simulator) ran: its cost model holds the + * coverage counters. A refresh replaces {@link #tool} with one no checker + * ran, so coverage is read from this one. + */ + private Tool runTool; + private GraphStore store; + private ModelChecker checker; + /** True after an incremental refresh: the store is current, the checker is not. */ + private boolean refreshed; + /** Whether the last adopted refresh explored its whole graph; meaningful once {@link #refreshed}. */ + private boolean refreshComplete; + /** + * The last fully explored graph and the tool it was explored under: what + * the next refresh replays from. Set by the first refresh from a finished + * run and advanced only by a refresh that explored its whole graph, so a + * refresh cut short (by its budget or a first violation) leaves the next + * one something sound to copy edges from. + */ + private Tool baseTool; + private GraphStore baseStore; + /** False when opened with {@code store: false}: no store, no store queries. */ + private boolean storing = true; + /** + * Set when a refresh that did not replace the tool left TLC's static + * tables unfit for the old spec: why a restart is needed. Every request + * that evaluates against the spec is refused from then on. + */ + private String restartRequired; + /** The model config's text when the store was built, to detect edits. */ + private String configText; + private String specDir; + private String mainFile; + private String configName; + private int workers; + private boolean checkDeadlock; + /** Set when opened in simulate mode: random behaviours instead of a graph. */ + private tlc2.tool.Simulator simulator; + private Thread simulatorThread; + private volatile Integer simulatorResult; + private volatile Throwable simulatorFailure; + /** A budget stop ends the simulator for good; later calls report it. */ + private boolean simulationStoppedByBudget; + private long simulationMs; + private Thread checkerThread; + /** + * Whether the run explored past violations when it last explored: what + * decides if a run that ended on a violation still covered the graph. + * {@link TLCGlobals#continuation} is not it, since a later request may + * set that after the run ended. + */ + private boolean runContinuation; + /** + * Whether any part of the run explored past violations. Once it has, TLC + * checked no further invariant on the states that violated one, so an + * invariant it never reported may still fail there, even if a later call + * resumed the run without continuation. + */ + private boolean everContinued; + private String metadir; + /** The metadir the session's own run was given at open; a refresh moves {@link #metadir} on. */ + private String runMetadir; + private volatile Integer resultCode; + private volatile Throwable checkerFailure; + private long openedAt; + private long exploringMs; + + public static void main(final String[] args) throws IOException { + // The protocol owns stdout; TLC's own printing goes to stderr. + final PrintStream protocol = new PrintStream(new FileOutputStream(FileDescriptor.out), true, + StandardCharsets.UTF_8.name()); + final PrintStream sink = new PrintStream(new FileOutputStream(FileDescriptor.err), true, + StandardCharsets.UTF_8.name()); + System.setOut(sink); + ToolIO.out = sink; + ToolIO.err = sink; + ToolIO.setMode(ToolIO.TOOL); + + final Resident resident = install(); + + final JsonObject ready = new JsonObject(); + ready.addProperty("event", "ready"); + ready.addProperty("tlc_version", TLCGlobals.Version.number()); + ready.addProperty("tlc_revision", TLCGlobals.Version.revisionOrDev()); + protocol.println(ready); + + final BufferedReader in = new BufferedReader( + new InputStreamReader(System.in, StandardCharsets.UTF_8)); + String line; + while ((line = in.readLine()) != null) { + if (line.isBlank()) { + continue; + } + JsonObject request; + try { + request = JsonParser.parseString(line).getAsJsonObject(); + } catch (final RuntimeException e) { + protocol.println(error(null, "invalid_request", "not a JSON object: " + e.getMessage())); + continue; + } + final JsonElement id = request.get("id"); + final String command = string(request, "command", ""); + JsonObject reply; + try { + reply = resident.dispatch(command, request); + } catch (final Throwable t) { + reply = error(id, "internal", t.toString()); + reply.add("messages", resident.recorder.drainMessages()); + } + if (id != null) { + reply.add("id", id); + } + reply.addProperty("command", command); + protocol.println(reply); + if ("close".equals(command)) { + break; + } + } + resident.shutdown(); + System.exit(0); + } + + /** A resident whose recorder receives TLC's messages; TLC's statics allow one per process. */ + static Resident install() { + final Resident resident = new Resident(); + MP.setRecorder(resident.recorder); + return resident; + } + + /** Serve one request, as a line of standard input would be served (the tests' entry). */ + JsonObject serve(final JsonObject request) throws Exception { + return dispatch(string(request, "command", ""), request); + } + + private JsonObject dispatch(final String command, final JsonObject request) throws Exception { + switch (command) { + case "check": + case "trace": + case "neighbours": + case "eval": + case "screen": + case "refresh": { + final JsonObject refusal = restartRefusal(); + if (refusal != null) { + return refusal; + } + break; + } + default: + break; + } + switch (command) { + case "open": + return open(request); + case "check": + return check(request); + case "stats": { + final JsonObject reply = ok(); + reply.add("stats", stats()); + markStale(reply); + return reply; + } + case "trace": + return trace(request); + case "neighbours": + return neighbours(request); + case "eval": + return eval(request); + case "screen": + return screen(request); + case "guard_profile": + return guardProfile(); + case "refresh": + return refresh(request); + case "simulate": + return simulate(request); + case "coverage": { + if (runTool == null) { + return notOpen(); + } + final JsonObject reply = ok(); + reply.add("coverage", tlc2.tool.coverage.CoverageWalk.walk(runTool)); + if (refreshed) { + reply.addProperty("stale", true); + reply.addProperty("stale_reason", + "the store was refreshed incrementally; coverage is the last full run's, over the spec as it was then"); + } + return reply; + } + case "registers": { + if (checker == null) { + return notOpen(); + } + final JsonObject reply = ok(); + reply.add("stats", stats()); + reply.add("registers", registers()); + markStale(reply); + return reply; + } + case "store": { + final JsonObject reply = ok(); + reply.add("store", storeInfo()); + return reply; + } + case "close": + return ok(); + default: + return error(null, "unknown_command", "no such request: " + command); + } + } + + // ─── open ─────────────────────────────────────────────────────────── + + private JsonObject open(final JsonObject request) { + if (tool != null) { + return error(null, "already_open", "this process already serves " + tool.getRootFile() + + "; close it and start another"); + } + final String spec = string(request, "spec", null); + if (spec == null) { + return error(null, "invalid_request", "open needs `spec`, the path of a .tla file"); + } + final File specFile = new File(spec).getAbsoluteFile(); + if (!specFile.isFile()) { + return error(null, "no_such_file", "no spec at " + specFile); + } + final String specDir = specFile.getParent() + FileUtil.separator; + final String mainFile = specFile.getName().replaceFirst("\\.tla$", ""); + String config = string(request, "config", null); + if (config == null) { + config = mainFile; + } else { + final File c = new File(config).getAbsoluteFile(); + config = c.getName().replaceFirst("\\.cfg$", ""); + if (!c.getParentFile().equals(specFile.getParentFile())) { + return error(null, "invalid_request", + "config must live next to the spec (TLC resolves it in the spec's directory)"); + } + } + final int workers = request.has("workers") ? request.get("workers").getAsInt() + : Runtime.getRuntime().availableProcessors(); + final boolean deadlock = !request.has("deadlock") || request.get("deadlock").getAsBoolean(); + final boolean coverage = !request.has("coverage") || request.get("coverage").getAsBoolean(); + final int fpIndex = request.has("fp_index") ? request.get("fp_index").getAsInt() : 0; + storing = !request.has("store") || request.get("store").getAsBoolean(); + if (request.has("metadir")) { + TLCGlobals.metaDir = new File(request.get("metadir").getAsString()).getAbsolutePath() + + FileUtil.separator; + } + + final String mode = string(request, "mode", "check"); + if (mode.equals("simulate")) { + return openSimulate(request, specFile, specDir, mainFile, config, workers, deadlock); + } + openedAt = System.currentTimeMillis(); + recorder.drainMessages(); + this.specDir = specDir; + this.mainFile = mainFile; + this.configName = config; + this.workers = workers; + try { + TLCGlobals.setNumWorkers(workers); + // Coverage is read into `static final` fields when ModelChecker and + // Worker load, so it is decided here, before either class is used. + // The interval only paces TLC's own printing, which goes to stderr. + TLCGlobals.coverageInterval = coverage ? Integer.MAX_VALUE : -1; + FP64.Init(fpIndex); + metadir = FileUtil.makeMetaDir(new Date(openedAt), specDir, null); + runMetadir = metadir; + tool = new FastTool(mainFile, config, new SimpleFilenameToStream(specDir), Tool.Mode.MC, + new HashMap<>()); + runTool = tool; + final boolean checkDeadlock = deadlock && tool.getModelConfig().getCheckDeadlock(); + this.checkDeadlock = checkDeadlock; + configText = readConfig(); + store = storing ? new GraphStore(metadir) : null; + checker = new ModelChecker(tool, metadir, + storing ? store : new tlc2.util.NoopStateWriter(), checkDeadlock, null, + FPSetFactory.getFPSetInitialized(new FPSetConfiguration(), metadir, specFile.getName()), + openedAt); + TLCGlobals.mainChecker = checker; + } catch (final Throwable t) { + tool = null; + runTool = null; + checker = null; + final JsonObject reply = error(null, "open_failed", t.toString()); + reply.add("messages", recorder.drainMessages()); + return reply; + } + + final JsonObject reply = ok(); + reply.addProperty("spec", specFile.getPath()); + reply.addProperty("root_module", tool.getRootName()); + reply.addProperty("metadir", metadir); + reply.addProperty("workers", workers); + reply.addProperty("check_deadlock", deadlock && tool.getModelConfig().getCheckDeadlock()); + reply.addProperty("coverage", coverage); + reply.addProperty("fp_index", fpIndex); + reply.addProperty("store", storing); + reply.add("catalogue", catalogue()); + reply.add("messages", recorder.drainMessages()); + return reply; + } + + /** + * Open for random simulation: a Tool in simulation mode and a + * {@link tlc2.tool.Simulator} with {@code depth} (default 100 steps per + * behaviour), {@code traces} (default unbounded: run until stopped or a + * violation) and {@code seed} (default random, reported). + */ + private JsonObject openSimulate(final JsonObject request, final File specFile, final String specDir, + final String mainFile, final String config, final int workers, final boolean deadlock) { + openedAt = System.currentTimeMillis(); + recorder.drainMessages(); + this.specDir = specDir; + this.mainFile = mainFile; + this.configName = config; + this.workers = workers; + final int depth = request.has("depth") ? request.get("depth").getAsInt() : 100; + final long traces = request.has("traces") ? request.get("traces").getAsLong() : Long.MAX_VALUE; + final tlc2.util.RandomGenerator rng = new tlc2.util.RandomGenerator(); + final long seed = request.has("seed") ? request.get("seed").getAsLong() : rng.nextLong(); + rng.setSeed(seed); + try { + TLCGlobals.setNumWorkers(workers); + TLCGlobals.coverageInterval = -1; + FP64.Init(0); + tlc2.value.RandomEnumerableValues.setSeed(seed); + metadir = FileUtil.makeMetaDir(new Date(openedAt), specDir, null); + runMetadir = metadir; + tool = new FastTool(mainFile, config, new SimpleFilenameToStream(specDir), Tool.Mode.Simulation, + new HashMap<>()); + runTool = tool; + // A non-null traceActions sizes the per-worker action-pair counters; + // anything but BASIC/FULL keeps TLC from writing its dot files. + simulator = new tlc2.tool.Simulator(tool, metadir, null, deadlock, depth, traces, "STATS", rng, seed, + new SimpleFilenameToStream(specDir), workers); + TLCGlobals.simulator = simulator; + } catch (final Throwable t) { + tool = null; + runTool = null; + simulator = null; + final JsonObject reply = error(null, "open_failed", t.toString()); + reply.add("messages", recorder.drainMessages()); + return reply; + } + final JsonObject reply = ok(); + reply.addProperty("mode", "simulate"); + reply.addProperty("spec", specFile.getPath()); + reply.addProperty("root_module", tool.getRootName()); + reply.addProperty("metadir", metadir); + reply.addProperty("workers", workers); + reply.addProperty("depth", depth); + reply.addProperty("traces", traces == Long.MAX_VALUE ? null : traces); + reply.addProperty("seed", seed); + reply.addProperty("extended_statistics", tlc2.tool.Simulator.EXTENDED_STATISTICS); + reply.add("catalogue", catalogue()); + reply.add("messages", recorder.drainMessages()); + return reply; + } + + /** + * Run random behaviours until {@code traces} of them, a violation, or + * {@code budget_ms}; then report what the recorder saw, the simulator's + * statistics and the action-pair follow matrix. A run that found nothing + * proves nothing: the verdict says so. + */ + private JsonObject simulate(final JsonObject request) throws InterruptedException { + if (simulator == null) { + return error(null, "not_simulating", "open with mode simulate first"); + } + final long budgetMs = request.has("budget_ms") ? request.get("budget_ms").getAsLong() : 60_000L; + final long started = System.currentTimeMillis(); + if (simulationStoppedByBudget) { + // Simulator.stop() is final: there is nothing to resume, so the + // stopped run is reported again rather than as a completed one. + final JsonObject reply = simulationReply(true); + reply.addProperty("resumable", false); + return reply; + } + if (simulatorThread == null) { + simulatorThread = new Thread(() -> { + try { + simulatorResult = simulator.simulate(); + } catch (final Throwable t) { + simulatorFailure = t; + } + }, "tlc-resident-simulator"); + simulatorThread.setDaemon(true); + simulatorThread.start(); + } + boolean stopped = false; + while (simulatorThread.isAlive()) { + if (System.currentTimeMillis() - started >= budgetMs) { + simulator.stop(); + stopped = true; + simulationStoppedByBudget = true; + simulatorThread.join(10_000); + break; + } + simulatorThread.join(20); + } + simulationMs += System.currentTimeMillis() - started; + return simulationReply(stopped); + } + + private JsonObject simulationReply(final boolean stopped) { + final JsonObject reply = ok(); + final boolean finished = !simulatorThread.isAlive(); + reply.addProperty("finished", finished); + reply.addProperty("stopped_by_budget", stopped); + final int outcome = recorder.outcome(); + final Integer code = simulatorResult; + if (finished && code != null) { + reply.addProperty("result_code", code); + } + if (simulatorFailure != null) { + reply.addProperty("verdict", "error"); + reply.addProperty("error", simulatorFailure.toString()); + } else if (outcome != EC.NO_ERROR) { + reply.addProperty("verdict", verdict(EC.GENERAL, outcome)); + } else if (finished && !simulationStoppedByBudget && code != null && code != EC.NO_ERROR) { + // The simulator ended on an error no violation code reports (the + // next-state relation failing to evaluate): an error, not a clean run. + reply.addProperty("verdict", "error"); + } else { + reply.addProperty("verdict", "no_violation_found"); + } + final String property = recorder.outcomeProperty(); + if (property != null) { + reply.addProperty("violated", property); + } + // No store in simulation: an initial state's violation keeps TLC's text. + recorder.completeInitial(null); + // The last complete behaviour TLC printed, as `check` reports it: + // an evaluation error's behaviour opens no violation trace. + final Recorder.Trace trace = recorder.trace(); + if (trace != null) { + reply.add("trace", traceJson(trace)); + } + final JsonArray all = new JsonArray(); + for (final Recorder.Trace t : recorder.traces()) { + all.add(traceJson(t)); + } + reply.add("traces", all); + try { + reply.add("statistics", Recorder.value(simulator.getStatistics(null))); + } catch (final Throwable t) { + reply.addProperty("statistics_error", t.toString()); + } + try { + reply.add("action_flow", simulator.actionFlowAsJson()); + } catch (final Throwable t) { + reply.addProperty("action_flow_error", t.toString()); + } + reply.addProperty("simulation_ms", simulationMs); + reply.add("messages", recorder.drainMessages()); + return reply; + } + + private JsonObject catalogue() { + final JsonObject c = new JsonObject(); + final JsonArray actions = new JsonArray(); + for (final Action a : tool.getActions()) { + final JsonObject o = new JsonObject(); + o.addProperty("id", a.getId()); + o.addProperty("name", a.getNameOfDefault()); + o.addProperty("location", a.getLocation()); + o.addProperty("internal", a.isInternal()); + actions.add(o); + } + c.add("actions", actions); + c.add("invariants", names(tool.getInvNames())); + c.add("implied_actions", names(tool.getImpliedActNames())); + // State-level PROPERTY formulas: checked on the initial states only. + c.add("implied_inits", names(tool.getImpliedInitNames())); + final JsonArray temporals = new JsonArray(); + for (final Action a : tool.getTemporals()) { + temporals.add(a.getNameOfDefault()); + } + c.add("temporal_properties", temporals); + final JsonArray implied = new JsonArray(); + for (final Action a : tool.getImpliedTemporals()) { + implied.add(a.getNameOfDefault()); + } + c.add("implied_temporals", implied); + final JsonArray vars = new JsonArray(); + if (TLCState.Empty != null) { + for (final String v : TLCState.Empty.getVarsAsStrings()) { + vars.add(v); + } + } + c.add("variables", vars); + c.addProperty("state_constraints", tool.getModelConstraints().length); + c.addProperty("action_constraints", tool.getActionConstraints().length); + c.addProperty("symmetry", tool.getSymmetryPerms() != null); + return c; + } + + private static JsonArray names(final String[] names) { + final JsonArray out = new JsonArray(); + if (names != null) { + for (final String n : names) { + out.add(n); + } + } + return out; + } + + // ─── check ────────────────────────────────────────────────────────── + + private JsonObject check(final JsonObject request) throws InterruptedException { + if (checker == null) { + return error(null, "not_open", "open a spec first"); + } + if (refreshed) { + return error(null, "refreshed", + "this session was refreshed incrementally: its store is current and the store queries serve it, but TLC's own checker is not; open a new session for a full run"); + } + final long budgetMs = request.has("budget_ms") ? request.get("budget_ms").getAsLong() : Long.MAX_VALUE; + final long budgetStates = request.has("budget_states") ? request.get("budget_states").getAsLong() + : Long.MAX_VALUE; + final long distinctAtStart = checker.getDistinctStatesGenerated(); + final long started = System.currentTimeMillis(); + // Traces per violated property under continuation (default 1: the + // first counterexample of each; later violations are counted only). + // Regenerating a trace re-runs the next-state relation from an initial + // state, which dominates a run whose invariant fails on most states. + TLCGlobals.continuationTraceLimit = request.has("traces_per_property") + ? request.get("traces_per_property").getAsInt() + : 1; + if (checkerThread == null) { + // The cap is per run: a resumed run keeps its count, so a later + // call does not print another trace for a property already traced. + TLCGlobals.resetContinuationTraces(); + } + + if (resultCode == null && checkerFailure == null) { + if (request.has("continue")) { + // Keep exploring past a violation, so one run reports every + // invariant's verdict. Process-global, as TLC's -continue is, + // and set only for a run that is about to explore: an ended + // run keeps the value it ran under. + TLCGlobals.continuation = request.get("continue").getAsBoolean(); + } + runContinuation = TLCGlobals.continuation; + everContinued |= runContinuation; + if (checkerThread == null) { + checkerThread = new Thread(() -> { + try { + resultCode = checker.modelCheck(); + } catch (final Throwable t) { + checkerFailure = t; + } finally { + // A run that ends before any worker starts (an + // initial state violates an invariant, init fails to + // evaluate) never finishes the queue, so a budget + // suspend waiting for the workers would wait forever. + // Finishing it wakes that wait; after a run that + // reached the workers it is already finished. + checker.theStateQueue.finishAll(); + } + }, "tlc-resident-checker"); + checkerThread.setDaemon(true); + checkerThread.start(); + } else { + checker.resume(); + } + // Wait for the run to end or the budget to run out. The queue's + // suspend blocks until every worker has parked, so on return the + // counters are quiescent. The initial states are generated before + // any worker runs and cannot be paused: a budget that runs out + // then takes effect once they are done (or the run has ended). + boolean suspended = false; + while (checkerThread.isAlive()) { + final long now = System.currentTimeMillis(); + final boolean overTime = now - started >= budgetMs; + final boolean overStates = checker.getDistinctStatesGenerated() - distinctAtStart >= budgetStates; + if (overTime || overStates) { + checker.suspend(); + suspended = true; + break; + } + checkerThread.join(20); + } + exploringMs += System.currentTimeMillis() - started; + if (!suspended) { + checkerThread.join(); + } + } + + final JsonObject reply = ok(); + final boolean finished = !checkerThread.isAlive(); + reply.addProperty("finished", finished); + reply.addProperty("budget_exhausted", !finished); + if (checkerFailure != null) { + reply.addProperty("verdict", "error"); + reply.addProperty("error", checkerFailure.toString()); + } else if (finished) { + reply.addProperty("result_code", resultCode); + String verdict = verdict(resultCode, recorder.outcome()); + final Integer error = errorCode(); + if (error != null) { + // An evaluation error cut some expansion short, whatever code + // the run ended on and whatever was violated before it. + reply.addProperty("error_code", error); + if (!"evaluation_failed".equals(verdict)) { + verdict = "error"; + reply.addProperty("error", + "TLC hit an error (code " + error + ") that left states unexpanded; the messages say what"); + } + } + reply.addProperty("verdict", verdict); + } else { + reply.addProperty("verdict", "unfinished"); + } + final String property = recorder.outcomeProperty(); + if (property != null) { + reply.addProperty("violated", property); + } + recorder.completeInitial(initialStates()); + final Recorder.Trace trace = recorder.trace(); + if (trace != null) { + reply.add("trace", traceJson(trace)); + } + // Under the per-property trace cap a violation past the cap is + // reported (and counted in the verdicts) but carries no states; + // those entries are not listed as traces. + final JsonArray all = new JsonArray(); + for (final Recorder.Trace t : recorder.traces()) { + if (!t.states.isEmpty()) { + all.add(traceJson(t)); + } + } + reply.add("traces", all); + // Reports past the cap are counted here instead of listed as messages. + reply.add("untraced_reports", recorder.untracedReports()); + reply.add("invariants", invariantVerdicts(finished)); + reply.addProperty("continuation", runContinuation); + reply.add("stats", stats()); + reply.add("messages", recorder.drainMessages()); + return reply; + } + + /** + * Maps the text TLC printed an initial state as, in a report of a + * violation there, to that state from the store: every initial state is + * stored before TLC checks it, those a constraint excludes included. The + * text is matched against each stored initial state printed as TLC + * printed it (through the ALIAS, if any), so the match is TLC's own + * state. Null without a store; the recorder then keeps the text. + */ + private java.util.function.Function initialStates() { + if (store == null) { + return null; + } + final Map byText = new HashMap<>(); + return text -> { + if (byText.isEmpty()) { + final long[] in = store.initialFingerprints(); + final long[] ex = store.excludedInitialFingerprints(); + final long[] all = java.util.Arrays.copyOf(in, in.length + ex.length); + System.arraycopy(ex, 0, all, in.length, ex.length); + for (final long fp : all) { + final TLCState s = store.read(fp); + if (s == null) { + continue; + } + try { + byText.putIfAbsent(tool.evalAlias(s, s).toString(), s); + } catch (final RuntimeException e) { + // An ALIAS that does not evaluate here: that state keeps its text. + } + } + } + final TLCState s = byText.get(text); + return s == null ? null : Recorder.state(s); + }; + } + + private static JsonObject traceJson(final Recorder.Trace trace) { + final JsonObject t = new JsonObject(); + t.addProperty("code", trace.code); + t.addProperty("property", trace.property); + t.addProperty("length", trace.states.size()); + t.addProperty("stuttering", trace.stuttering); + if (trace.lassoTo != null) { + t.addProperty("lasso_to", trace.lassoTo); + } + final JsonArray states = new JsonArray(); + for (final JsonObject s : trace.states) { + states.add(s); + } + t.add("states", states); + return t; + } + + /** + * One verdict per configured invariant. A violated one carries the level + * and last action of the first counterexample TLC printed for it, and how + * many times it was reported (more than one only under continuation). One + * that was never reported is `no_violation_found` when the run finished + * without error and `not_evaluated` otherwise: a run that stopped at the + * first violation, or at its budget, evaluated it on some states only. + */ + private JsonArray invariantVerdicts(final boolean finished) { + final JsonArray out = new JsonArray(); + final Map counts = recorder.violationCounts(); + final Map failures = recorder.evaluationFailures(); + final List traces = recorder.traces(); + final boolean exhausted = finished && explorationComplete(); + // Under continuation TLC reports the first invariant a state violates + // and checks no further invariant on that state. An invariant never + // reported may then fail only on states an earlier one failed on, so + // its silence is no verdict: it is evaluated over the stored graph. + final java.util.Set unreported = new java.util.LinkedHashSet<>(); + boolean anyViolated = false; + for (final String name : tool.getInvNames()) { + if (counts.containsKey(name)) { + anyViolated = true; + } else if (!failures.containsKey(name)) { + unreported.add(name); + } + } + // Any part of the run under continuation, not only its last call: a + // run resumed without it keeps the states it already skipped past. + final boolean skipped = exhausted && everContinued && anyViolated && !unreported.isEmpty(); + final Map swept = new HashMap<>(); + if (skipped && store != null) { + for (final Incremental.Sweep sw : Incremental.sweep(tool, store, unreported)) { + if (unreported.contains(sw.invariant)) { + swept.put(sw.invariant, sw); + } + } + } + for (final String name : tool.getInvNames()) { + final JsonObject v = new JsonObject(); + v.addProperty("name", name); + final Integer n = counts.get(name); + final String failure = failures.get(name); + if (n != null || failure != null) { + if (n != null) { + v.addProperty("verdict", "violated"); + v.addProperty("reports", n); + } else { + // It did not evaluate on some state: no verdict either way. + v.addProperty("verdict", "not_evaluable"); + } + if (failure != null) { + v.addProperty("error", failure); + } + // The first trace of the verdict's own kind. + final boolean violation = n != null; + for (final Recorder.Trace t : traces) { + if (name.equals(t.property) + && violation == (t.code != EC.TLC_INVARIANT_EVALUATION_FAILED)) { + v.addProperty("level", t.states.size()); + if (!t.states.isEmpty()) { + v.add("action", t.states.get(t.states.size() - 1).get("action")); + } + break; + } + } + } else if (skipped) { + final Incremental.Sweep sw = swept.get(name); + if (sw == null) { + v.addProperty("verdict", "not_evaluated"); + v.addProperty("reason", + "under continuation TLC checks no further invariant on a state that violates one, and without a store the skipped states cannot be revisited"); + } else if (sw.error != null) { + v.addProperty("verdict", "not_evaluable"); + v.addProperty("error", sw.error); + } else if (sw.violations > 0) { + // Found over the store, not reported by TLC: no trace. + v.addProperty("verdict", "violated"); + v.addProperty("reports", sw.violations); + v.addProperty("level", sw.firstLevel); + v.addProperty("fp", sw.firstFp); + v.addProperty("source", "store"); + } else { + v.addProperty("verdict", "no_violation_found"); + } + } else { + v.addProperty("verdict", exhausted ? "no_violation_found" : "not_evaluated"); + } + out.add(v); + } + return out; + } + + private static String verdict(final int code, final int outcome) { + switch (outcome) { + case EC.TLC_INVARIANT_VIOLATED_INITIAL: + case EC.TLC_INVARIANT_VIOLATED_BEHAVIOR: + case EC.TLC_INVARIANT_VIOLATED_LEVEL: + return "invariant_violated"; + case EC.TLC_ACTION_PROPERTY_VIOLATED_BEHAVIOR: + return "action_property_violated"; + case EC.TLC_TEMPORAL_PROPERTY_VIOLATED: + return "temporal_property_violated"; + case EC.TLC_PROPERTY_VIOLATED_INITIAL: + // A PROPERTY that is false in an initial state. + return "property_violated"; + case EC.TLC_DEADLOCK_REACHED: + return "deadlock"; + case EC.TLC_INVARIANT_EVALUATION_FAILED: + return "evaluation_failed"; + default: + return code == EC.NO_ERROR ? "ok" : "error"; + } + } + + /** + * Whether the checker explored the whole reachable graph: it ended on its + * own with nothing left in its queue, and on a code that never cuts a + * state's expansion short. A deadlock is found after the state's (empty) + * expansion and a temporal violation after the graph is complete; an + * invariant or action-property violation aborts the expansion it occurs + * in unless TLC continues past violations. Any other error may have. + */ + private boolean explorationComplete() { + if (checkerThread == null || checkerThread.isAlive() || checkerFailure != null || resultCode == null + || checker.getStateQueueSize() != 0 || errorCode() != null) { + return false; + } + switch (resultCode) { + case EC.NO_ERROR: + case EC.TLC_DEADLOCK_REACHED: + case EC.TLC_TEMPORAL_PROPERTY_VIOLATED: + return true; + case EC.TLC_INVARIANT_VIOLATED_INITIAL: + case EC.TLC_INVARIANT_VIOLATED_BEHAVIOR: + case EC.TLC_ACTION_PROPERTY_VIOLATED_BEHAVIOR: + return runContinuation; + default: + return false; + } + } + + /** + * The first error the run hit that is not a violation, or null. Under + * continuation TLC keeps going after an evaluation error and a later code + * overwrites it (the state whose expansion the error aborted is then + * reported as a deadlock), so the result code alone does not say whether + * every state was fully expanded; the checker keeps every code it set. + */ + private Integer errorCode() { + if (checker == null) { + return null; + } + for (final int code : checker.getErrorCodes()) { + if (!isViolation(code)) { + return code; + } + } + return null; + } + + /** Whether a result code reports a property violated (or a deadlock), not an error. */ + private static boolean isViolation(final int code) { + switch (code) { + case EC.TLC_INVARIANT_VIOLATED_INITIAL: + case EC.TLC_INVARIANT_VIOLATED_BEHAVIOR: + case EC.TLC_INVARIANT_VIOLATED_LEVEL: + case EC.TLC_ACTION_PROPERTY_VIOLATED_BEHAVIOR: + case EC.TLC_TEMPORAL_PROPERTY_VIOLATED: + case EC.TLC_PROPERTY_VIOLATED_INITIAL: + case EC.TLC_DEADLOCK_REACHED: + return true; + default: + return false; + } + } + + // ─── stats ────────────────────────────────────────────────────────── + + private JsonObject stats() { + final JsonObject s = new JsonObject(); + if (checker == null) { + return s; + } + final JsonObject fin = recorder.finalStats(); + if (fin != null && resultCode != null) { + // After the run the fingerprint set is closed; the counts TLC + // printed at the end are the ones on record. + s.add("generated", fin.get("generated")); + s.add("distinct", fin.get("distinct")); + s.add("queue", fin.get("queue")); + } else { + s.addProperty("generated", checker.getStatesGenerated()); + s.addProperty("distinct", checker.getDistinctStatesGenerated()); + s.addProperty("queue", checker.getStateQueueSize()); + } + s.addProperty("initial", checker.getInitialStatesGenerated()); + s.addProperty("diameter", checker.getProgress()); + s.addProperty("exploring_ms", exploringMs); + s.addProperty("since_open_ms", System.currentTimeMillis() - openedAt); + s.addProperty("running", checkerThread != null && checkerThread.isAlive()); + s.addProperty("finished", resultCode != null); + s.add("store", storeInfo()); + return s; + } + + /** + * The counters TLC keeps beyond the headline statistics: whether the + * reachable graph was exhausted and what stopped the run, the workers' + * out-degree distribution (bucketed, the top bucket saturating at 32), + * the fingerprint set's own statistics, the fingerprint-collision + * probability, and the liveness checkers in play. + */ + private JsonObject registers() { + final JsonObject r = new JsonObject(); + final boolean finished = resultCode != null; + // The same judgement a refresh relies on: an error (an invariant or + // the next-state relation failing to evaluate) ends the run with + // states unexplored, continuation or not. + final boolean exhausted = finished && explorationComplete(); + r.addProperty("finished", finished); + r.addProperty("exhausted", exhausted); + r.addProperty("stopped_by", checkerFailure != null || (finished && errorCode() != null) ? "error" + : !finished ? (checkerThread == null ? "not_started" : "budget") + // Under continuation a run that reported violations still ends + // on NO_ERROR, so the recorder, not the code, says if it found any. + : exhausted ? (resultCode == EC.NO_ERROR && recorder.violationCounts().isEmpty() ? "exhausted" + : "exhausted_with_violations") + : isViolation(resultCode) ? "violation" : "error"); + if (finished) { + r.addProperty("result_code", resultCode); + } + r.addProperty("workers", TLCGlobals.getNumWorkers()); + r.addProperty("continuation", runContinuation); + r.addProperty("coverage", tlc2.tool.coverage.CoverageWalk.enabled()); + // Out-degree across workers. + final JsonObject outDegree = new JsonObject(); + long observations = 0; + int min = Integer.MAX_VALUE; + int max = -1; + double weightedMean = 0; + final java.util.TreeMap samples = new java.util.TreeMap<>(); + for (final tlc2.tool.IWorker w : checker.getWorkers()) { + if (!(w instanceof tlc2.tool.Worker)) { + continue; + } + final tlc2.util.statistics.IBucketStatistics b = ((tlc2.tool.Worker) w).getOutDegree(); + if (b == null || b.getObservations() == 0) { + continue; + } + observations += b.getObservations(); + min = Math.min(min, b.getMin()); + max = Math.max(max, b.getMax()); + weightedMean += b.getMean() * b.getObservations(); + b.getSamples().forEach((k, v) -> samples.merge(k, v, Long::sum)); + } + if (observations > 0) { + outDegree.addProperty("observations", observations); + outDegree.addProperty("min", min); + outDegree.addProperty("max", max); + outDegree.addProperty("mean", weightedMean / observations); + outDegree.addProperty("saturated_at", 32); + final JsonObject buckets = new JsonObject(); + samples.forEach((k, v) -> buckets.addProperty(String.valueOf(k), v)); + outDegree.add("buckets", buckets); + } + r.add("out_degree", outDegree); + // The fingerprint set. + final JsonObject fpset = new JsonObject(); + fpset.addProperty("implementation", checker.theFPSet.getClass().getSimpleName()); + if (checker.theFPSet instanceof tlc2.tool.fp.FPSetStatistic) { + final tlc2.tool.fp.FPSetStatistic f = (tlc2.tool.fp.FPSetStatistic) checker.theFPSet; + try { + fpset.addProperty("table_count", f.getTblCnt()); + fpset.addProperty("disk_lookups", f.getDiskLookupCnt()); + fpset.addProperty("memory_hits", f.getMemHitCnt()); + fpset.addProperty("disk_hits", f.getDiskHitCnt()); + fpset.addProperty("disk_writes", f.getDiskWriteCnt()); + fpset.addProperty("flush_time_ms", f.getFlushTime()); + fpset.addProperty("bytes", f.sizeof()); + } catch (final RuntimeException e) { + fpset.addProperty("error", e.toString()); + } + } + r.add("fpset", fpset); + final long generated = checker.getStatesGenerated(); + final long distinct = checker.getDistinctStatesGenerated(); + if (distinct > 0 && generated > 0) { + r.addProperty("fp_collision_probability", + tlc2.tool.AbstractChecker.calculateOptimisticProbability(distinct, generated)); + } + // Liveness. + final JsonObject liveness = new JsonObject(); + liveness.addProperty("enabled", TLCGlobals.doLiveness()); + liveness.addProperty("temporal_properties", tool.getTemporals().length); + r.add("liveness", liveness); + return r; + } + + private JsonObject storeInfo() { + final JsonObject o = new JsonObject(); + if (store == null) { + return o; + } + o.addProperty("states", store.states()); + o.addProperty("initial", store.initialStates()); + o.addProperty("edges", store.edges()); + o.addProperty("unsatisfied", store.unsatisfied()); + o.addProperty("excluded", store.excluded()); + o.addProperty("excluded_states", store.excludedStates()); + o.addProperty("bytes", store.bytes()); + return o; + } + + // ─── the store's queries ──────────────────────────────────────────── + + private JsonObject notOpen() { + if (simulator != null) { + return error(null, "no_store", "this is a simulate session, which keeps no store; open a check session to query one"); + } + if (tool != null && !storing) { + return error(null, "no_store", "this session was opened with store: false; reopen with the store to query it"); + } + return error(null, "not_open", "open a spec first"); + } + + private static Long fpOf(final JsonObject request, final String key) { + if (!request.has(key) || request.get(key).isJsonNull()) { + return null; + } + final JsonElement e = request.get(key); + try { + return e.getAsJsonPrimitive().isNumber() ? e.getAsLong() : Long.parseLong(e.getAsString()); + } catch (final RuntimeException ex) { + return null; + } + } + + private String actionName(final long id) { + final Action a = store.action((int) id); + return a == null ? (id < 0 ? "" : "action#" + id) : a.getNameOfDefault(); + } + + /** + * Re-parse the spec after an edit and re-explore only what the edit + * reaches (see {@link Incremental}). A change to the variables, the + * initial predicate, a constraint or the config, a VIEW or SYMMETRY set, + * a spec that reads {@code TLCGet}, or a first run that did not + * finish, leaves nothing to carry: + * the reply asks for a restart and a full run. + * + *

+ * The replay starts from the last fully explored graph ({@link #baseStore}), + * not necessarily the current store: a refresh cut short by its budget or + * a first violation is served to the store queries but not replayed from. + * A replay that fails (the edited spec does not evaluate) is not adopted: + * the current tool and store stay. Neither is one whose assumptions fail: + * TLC would not explore it. State-level {@code PROPERTY} formulas are + * checked on the initial states, as TLC checks them. + * + *

+ * A paused checker is left parked, not stopped: stopping ends its run as + * if it had finished. Parsing the edited spec rebinds TLC's static + * variable tables, so when the new tool is not adopted they are rebound + * to the old one, and the parked run may resume only if that is exact. + */ + private JsonObject refresh(final JsonObject request) throws Exception { + if (simulator != null) { + return error(null, "not_checking", + "refresh replays a model-checking store; a simulate session has none. Open a new session"); + } + if (tool == null) { + return notOpen(); + } + final long budgetMs = request.has("budget_ms") ? request.get("budget_ms").getAsLong() : 60_000L; + final boolean cont = request.has("continue") && request.get("continue").getAsBoolean(); + final long started = System.currentTimeMillis(); + recorder.drainMessages(); + // Decided before parsing, so these paths leave TLC's statics alone. + final String currentConfig = readConfig(); + String before = null; + if (!storing) { + before = "the session was opened without a store, so there is no graph to replay"; + } else if (currentConfig == null || !currentConfig.equals(configText)) { + before = "the model config changed"; + } else if (!refreshed && !explorationComplete()) { + before = "the previous exploration did not finish, so the store is not the whole graph"; + } + if (before != null) { + return fullRerun(ok(), before, started); + } + if (baseStore == null) { + // The first refresh, from a run that explored the whole graph. + baseTool = tool; + baseStore = store; + } + // Parsing rebinds the process-global name slots and resets the + // model-value table; kept to put them back if the edited spec is not + // adopted. + final Statics slots = statics(); + final Tool newTool; + try { + newTool = new FastTool(mainFile, configName, new SimpleFilenameToStream(specDir), Tool.Mode.MC, + new HashMap<>()); + } catch (final Throwable t) { + final JsonObject reply = error(null, "parse_failed", t.toString()); + reply.add("messages", recorder.drainMessages()); + // The current tool and store stay in place. + rebindStatics(tool, slots); + return reply; + } + final Incremental.ActionDiff diff = Incremental.diff(baseTool, newTool); + final JsonObject reply = ok(); + reply.add("diff", Incremental.diffJson(diff)); + reply.addProperty("front_end_ms", System.currentTimeMillis() - started); + if (diff.fullRerunReason == null && !slots.modelValues.isPrefixOf(tlc2.value.impl.ModelValue.snapshot())) { + // Stored states name a model value by its index in the table the + // parse rebuilt; a renumbered one would decode as another value. + diff.fullRerunReason = "the model values were renumbered, so stored states would decode as other states"; + } + if (diff.fullRerunReason != null) { + rebindStatics(tool, slots); + return fullRerun(reply, diff.fullRerunReason, started); + } + reply.addProperty("mode", "incremental"); + // TLC checks the assumptions before it explores anything; an edited + // ASSUME is checked here too, since the constants it constrains are + // the config's and a refresh never runs the checker's own check. + final int assumptions = newTool.checkAssumptions(); + if (assumptions != EC.NO_ERROR) { + rebindStatics(tool, slots); + reply.addProperty("adopted", false); + reply.addProperty("finished", false); + reply.addProperty("complete", false); + reply.addProperty("verdict", + assumptions == EC.TLC_ASSUMPTION_FALSE ? "assumption_false" : "assumption_evaluation_failed"); + reply.addProperty("error", assumptions == EC.TLC_ASSUMPTION_FALSE + ? "an assumption of the edited spec is false; the messages name it" + : "an assumption of the edited spec did not evaluate; the messages say why"); + reply.addProperty("duration_ms", System.currentTimeMillis() - started); + reply.add("store", storeInfo()); + reply.add("messages", recorder.drainMessages()); + return reply; + } + reply.addProperty("assumptions", "hold"); + reply.addProperty("replayed_from", baseStore == store ? "current" : "last_complete"); + final String newMetadir = FileUtil.makeMetaDir(new Date(System.currentTimeMillis()), specDir, null); + final GraphStore newStore = new GraphStore(newMetadir); + Incremental.Result r; + try { + r = Incremental.replay(newTool, baseStore, newStore, diff, budgetMs, cont); + } catch (final Throwable t) { + // Anything the replay did not turn into an error of its own still + // leaves the edited spec unadopted, and is cleaned up below. + r = new Incremental.Result(); + r.error = t.toString(); + } + reply.addProperty("survivors", r.survivors); + reply.addProperty("dropped", r.dropped); + reply.addProperty("reexpanded", r.reexpanded); + reply.addProperty("new_states", r.newStates); + reply.addProperty("edges_copied", r.edgesCopied); + reply.addProperty("edges_generated", r.edgesGenerated); + reply.addProperty("budget_exhausted", r.budgetExhausted); + if (r.restartReason != null) { + // A stored state did not read back as itself: nothing can be carried. + newStore.dispose(); + rebindStatics(tool, slots); + reply.addProperty("adopted", false); + return fullRerun(reply, r.restartReason, started); + } + if (r.error != null) { + // The edited spec does not evaluate; keep serving what was there. + newStore.dispose(); + rebindStatics(tool, slots); + reply.addProperty("adopted", false); + reply.addProperty("finished", false); + reply.addProperty("complete", false); + reply.addProperty("error", r.error); + reply.addProperty("duration_ms", System.currentTimeMillis() - started); + reply.add("store", storeInfo()); + reply.add("messages", recorder.drainMessages()); + return reply; + } + final GraphStore previous = store; + tool = newTool; + store = newStore; + metadir = newMetadir; + refreshed = true; + // Only a replay that ran to the end holds the whole graph: one cut + // by its budget or a first violation leaves states whose successors + // were never generated. Such a store is served, not replayed from. + final boolean stoppedAtViolation = !cont && !r.violations.isEmpty(); + final boolean complete = !r.budgetExhausted && !stoppedAtViolation; + refreshComplete = complete; + if (complete) { + final GraphStore oldBase = baseStore; + baseTool = newTool; + baseStore = newStore; + retire(oldBase); + } + retire(previous); + reply.addProperty("adopted", true); + reply.addProperty("finished", !r.budgetExhausted); + reply.addProperty("complete", complete); + reply.addProperty("stopped_at_first_violation", stoppedAtViolation); + final JsonArray violations = new JsonArray(); + for (final Incremental.Violation v : r.violations) { + final JsonObject o = new JsonObject(); + o.addProperty("invariant", v.invariant); + o.addProperty("fp", v.fp); + o.addProperty("level", v.level); + violations.add(o); + } + // Per-invariant verdicts: exact over the refreshed store when the + // replay explored the whole graph (one evaluation per state and + // invariant), else not_evaluated. + final JsonArray invs = new JsonArray(); + if (complete) { + final JsonArray exact = new JsonArray(); + for (final Incremental.Sweep sw : Incremental.sweep(tool, store)) { + final JsonObject v = new JsonObject(); + v.addProperty("name", sw.invariant); + if (sw.error != null) { + v.addProperty("verdict", "not_evaluable"); + v.addProperty("error", sw.error); + } else if (sw.violations > 0) { + v.addProperty("verdict", "violated"); + v.addProperty("reports", sw.violations); + v.addProperty("level", sw.firstLevel); + v.addProperty("fp", sw.firstFp); + final JsonObject o = new JsonObject(); + o.addProperty("invariant", sw.invariant); + o.addProperty("fp", sw.firstFp); + o.addProperty("level", sw.firstLevel); + o.addProperty("count", sw.violations); + exact.add(o); + } else { + v.addProperty("verdict", "no_violation_found"); + } + invs.add(v); + } + reply.add("violations", exact); + } else { + for (final String name : tool.getInvNames()) { + final JsonObject v = new JsonObject(); + v.addProperty("name", name); + Incremental.Violation first = null; + for (final Incremental.Violation x : r.violations) { + if (x.invariant.equals(name)) { + first = x; + break; + } + } + if (first != null) { + v.addProperty("verdict", "violated"); + v.addProperty("level", first.level); + v.addProperty("fp", first.fp); + } else { + v.addProperty("verdict", "not_evaluated"); + } + invs.add(v); + } + reply.add("violations", violations); + } + reply.add("invariants", invs); + // State-level PROPERTY formulas, which TLC checks on the initial + // states only. The initial predicate is unchanged, so every initial + // state is in the refreshed store, whatever stopped the replay. + final JsonArray props = new JsonArray(); + for (final Incremental.Sweep sw : Incremental.impliedInits(tool, store)) { + final JsonObject v = new JsonObject(); + v.addProperty("name", sw.invariant); + if (sw.error != null) { + v.addProperty("verdict", "not_evaluable"); + v.addProperty("error", sw.error); + } else if (sw.violations > 0) { + v.addProperty("verdict", "violated_initially"); + v.addProperty("reports", sw.violations); + v.addProperty("fp", sw.firstFp); + } else { + v.addProperty("verdict", "no_violation_found"); + } + props.add(v); + } + reply.add("implied_inits", props); + reply.add("unchecked", unchecked()); + reply.addProperty("duration_ms", System.currentTimeMillis() - started); + reply.add("store", storeInfo()); + reply.add("messages", recorder.drainMessages()); + return reply; + } + + /** + * What a refresh does not recheck, so a caller does not read the + * invariant verdicts as the whole answer (it does check the assumptions + * and the state-level properties, under {@code implied_inits}): temporal properties (the + * liveness tableau is not rebuilt), implied actions, deadlock, and the + * blocked-guard tallies (not recorded during a replay). + */ + private JsonObject unchecked() { + final JsonObject o = new JsonObject(); + final JsonArray temporals = new JsonArray(); + for (final Action a : tool.getTemporals()) { + temporals.add(a.getNameOfDefault()); + } + for (final Action a : tool.getImpliedTemporals()) { + temporals.add(a.getNameOfDefault()); + } + o.add("temporal_properties", temporals); + o.add("implied_actions", names(tool.getImpliedActNames())); + o.addProperty("deadlock", checkDeadlock); + o.addProperty("guard_tallies", true); + return o; + } + + /** + * After an incremental refresh the checker's counters and registers + * describe the run before it, not the refreshed store: say so, and say + * whether the store now holds the whole graph, as the refresh reported. + */ + private void markStale(final JsonObject reply) { + if (!refreshed) { + return; + } + reply.addProperty("stale", true); + reply.addProperty("stale_reason", + "the store was refreshed incrementally; the counters and registers are the last full run's, over the spec as it was then (only `store` is current)"); + reply.addProperty("refresh_complete", refreshComplete); + } + + /** Release a store nothing refers to any more. */ + private void retire(final GraphStore s) { + if (s != null && s != store && s != baseStore) { + s.dispose(); + } + } + + /** + * Nothing can be carried. A second checker in this JVM trips over TLC's + * per-process state (worker and trace bookkeeping), so the caller + * restarts the resident for the full run; the old tool and store stay in + * place until then. + */ + private JsonObject fullRerun(final JsonObject reply, final String reason, final long started) { + reply.addProperty("mode", "full"); + reply.addProperty("restart_required", true); + reply.addProperty("reason", reason); + reply.addProperty("duration_ms", System.currentTimeMillis() - started); + reply.add("messages", recorder.drainMessages()); + return reply; + } + + /** The model config's text, or null when it cannot be read. */ + private String readConfig() { + try { + return new String(java.nio.file.Files.readAllBytes(new File(specDir, configName + ".cfg").toPath()), + StandardCharsets.UTF_8); + } catch (final IOException e) { + return null; + } + } + + /** + * Rebind TLC's static variable tables to {@code old} after parsing a spec + * that was not adopted. Parsing assigns each variable name its slot and + * sets the variable count, the empty state and the state's tool, and it + * resets the model-value table; this puts the old spec's back, the + * model-value table and every name's slot as {@code saved} recorded + * them before the parse (a definition of the old spec that the new one + * declares a variable would otherwise evaluate as that variable, and a + * stored model value would decode against the edited spec's + * numbering). Should a definition still read as a variable, nothing that + * evaluates against the spec is served any more. + */ + private void rebindStatics(final Tool old, final Statics saved) { + // Every name's slot as it was before the parse: a definition of the + // old spec that the edited one declares a variable gets its definition + // slot back. A name first seen in the parse gets none. + for (final util.UniqueString u : util.UniqueString.internTbl.toMap().values()) { + final Integer loc = saved.slots.get(u); + u.setLoc(loc == null ? -1 : loc); + } + // The model values the stored states were serialised against: the + // parse reset the table, and a stored state names a model value by + // its index there. + tlc2.value.impl.ModelValue.restore(saved.modelValues); + final tla2sany.semantic.OpDeclNode[] vars = old.getSpecProcessor().getVariablesNodes(); + for (int i = 0; i < vars.length; i++) { + vars[i].getName().setLoc(i); + } + util.UniqueString.setVariableCount(vars.length); + tlc2.tool.TLCStateMut.setVariables(vars); + tlc2.tool.TLCStateMut.setTool(old); + final OpDefNode[] defs = old.getSpecProcessor().getRootModule().getOpDefs(); + for (final OpDefNode def : defs == null ? new OpDefNode[0] : defs) { + if (def.getName().getVarLoc() >= 0) { + restartRequired = "parsing the edited spec reassigned the definition " + def.getName(); + return; + } + } + } + + /** What a parse overwrites in TLC's process-global state, taken before it. */ + private static final class Statics { + /** Every interned name's slot in a state or the definition table (-1 for none). */ + final Map slots = new java.util.IdentityHashMap<>(); + /** The model-value table, which every parse resets. */ + final tlc2.value.impl.ModelValue.Table modelValues = tlc2.value.impl.ModelValue.snapshot(); + } + + private static Statics statics() { + final Statics out = new Statics(); + for (final util.UniqueString u : util.UniqueString.internTbl.toMap().values()) { + // A slot is either a variable's or a definition's. + out.slots.put(u, Math.max(u.getVarLoc(), u.getDefnLoc())); + } + return out; + } + + /** + * The refusal every request that evaluates against the spec gives once a + * refresh left TLC's statics unfit for it, or null. + */ + private JsonObject restartRefusal() { + if (restartRequired == null) { + return null; + } + return error(null, "restart_required", + "this session cannot evaluate against its spec any more: " + restartRequired + "; open a new session"); + } + + /** The path from an initial state to a stored fingerprint. */ + private JsonObject trace(final JsonObject request) { + if (store == null) { + return notOpen(); + } + final Long fp = fpOf(request, "fp"); + if (fp == null) { + return error(null, "invalid_request", "trace needs `fp`, a stored fingerprint"); + } + final long[][] path = store.pathTo(fp); + if (path == null) { + return error(null, "unknown_state", "no stored state with fingerprint " + fp); + } + final JsonArray states = new JsonArray(); + for (int i = 0; i < path.length; i++) { + final TLCState state = store.read(path[i][0]); + final JsonObject s = state == null ? new JsonObject() : Recorder.state(state); + s.addProperty("ordinal", i + 1); + s.addProperty("fp", path[i][0]); + s.addProperty("action", actionName(path[i][1])); + states.add(s); + } + final JsonObject reply = ok(); + reply.addProperty("fp", fp); + reply.addProperty("length", path.length); + reply.addProperty("shortest", TLCGlobals.getNumWorkers() == 1); + reply.add("states", states); + return reply; + } + + /** Recorded predecessors of a stored state and, freshly computed, its successors per action. */ + private JsonObject neighbours(final JsonObject request) { + if (store == null) { + return notOpen(); + } + final Long fp = fpOf(request, "fp"); + if (fp == null) { + return error(null, "invalid_request", "neighbours needs `fp`, a stored fingerprint"); + } + final TLCState state = store.read(fp); + if (state == null) { + return error(null, "unknown_state", "no stored state with fingerprint " + fp); + } + final JsonObject reply = ok(); + reply.addProperty("fp", fp); + reply.addProperty("level", store.level(fp)); + reply.add("state", Recorder.state(state)); + final JsonArray preds = new JsonArray(); + for (final long[] p : store.predecessorsOf(fp)) { + final JsonObject o = new JsonObject(); + o.addProperty("fp", p[0]); + o.addProperty("action", actionName(p[1])); + o.addProperty("action_id", p[1]); + o.addProperty("seen_before", (p[2] & tlc2.util.IStateWriter.IsSeen) != 0); + preds.add(o); + } + reply.add("predecessors", preds); + final JsonArray succs = new JsonArray(); + int enabled = 0; + for (final Action a : tool.getActions()) { + final JsonObject o = new JsonObject(); + o.addProperty("action", a.getNameOfDefault()); + o.addProperty("action_id", a.getId()); + try { + // Not counted in the run's coverage: this query is not part of the run. + final StateVec next = tool.getNextStatesUnrecorded(a, state); + o.addProperty("enabled", next.size() > 0); + o.addProperty("successors", next.size()); + if (next.size() > 0) { + enabled++; + } + final JsonArray fps = new JsonArray(); + for (int i = 0; i < next.size(); i++) { + final TLCState succ = next.elementAt(i); + final JsonObject so = new JsonObject(); + long sfp = 0; + try { + sfp = succ.fingerPrint(); + } catch (final RuntimeException e) { + so.addProperty("unassigned", true); + } + so.addProperty("fp", sfp); + so.addProperty("stored", store.contains(sfp)); + so.add("changed", changed(state, succ)); + fps.add(so); + } + o.add("states", fps); + } catch (final Throwable t) { + o.addProperty("enabled", (Boolean) null); + o.addProperty("error", t.toString()); + } + succs.add(o); + } + reply.addProperty("enabled_actions", enabled); + reply.add("successors", succs); + return reply; + } + + /** The variables whose values differ between two states. */ + private static JsonArray changed(final TLCState a, final TLCState b) { + final JsonArray out = new JsonArray(); + final Map va = a.getVals(); + final Map vb = b.getVals(); + if (va == null || vb == null) { + return out; + } + for (final Map.Entry e : vb.entrySet()) { + final IValue before = va.get(e.getKey()); + if (before == null || !before.equals(e.getValue())) { + out.add(e.getKey().toString()); + } + } + return out; + } + + /** Parse a caller expression against the root module, as the debugger does. */ + private OpDefNode parse(final String expression) throws Exception { + final tlc2.tool.impl.SpecProcessor proc = tool.getSpecProcessor(); + final OpDefNode def = TLCDebuggerExpression.process(proc, proc.getRootModule(), expression); + if (def == null) { + throw new IllegalArgumentException("could not parse expression: " + expression); + } + return def; + } + + /** Evaluate an expression in a stored state, or over a stored state pair. */ + private JsonObject eval(final JsonObject request) { + if (store == null) { + return notOpen(); + } + final String expression = string(request, "expr", null); + final Long fp = fpOf(request, "fp"); + if (expression == null || fp == null) { + return error(null, "invalid_request", "eval needs `expr` and `fp` (and optionally `fp2` for a primed expression)"); + } + final TLCState s0 = store.read(fp); + if (s0 == null) { + return error(null, "unknown_state", "no stored state with fingerprint " + fp); + } + final Long fp2 = fpOf(request, "fp2"); + TLCState s1 = null; + if (fp2 != null) { + s1 = store.read(fp2); + if (s1 == null) { + return error(null, "unknown_state", "no stored state with fingerprint " + fp2); + } + } + final JsonObject reply = ok(); + reply.addProperty("expr", expression); + reply.addProperty("fp", fp); + final long started = System.currentTimeMillis(); + try { + final OpDefNode def = parse(expression); + final IValue value = s1 == null ? tool.eval(def.getBody(), Context.Empty, s0) + : tool.eval(def.getBody(), Context.Empty, s0, s1, EvalControl.Clear, CostModel.DO_NOT_RECORD); + reply.addProperty("evaluated", true); + reply.add("value", Recorder.value(value)); + reply.addProperty("tla", value.toString()); + } catch (final Throwable t) { + reply.addProperty("evaluated", false); + reply.addProperty("error", t.getMessage() == null ? t.toString() : t.getMessage()); + } + reply.addProperty("duration_ms", System.currentTimeMillis() - started); + reply.add("messages", recorder.drainMessages()); + return reply; + } + + /** Evaluate candidate state predicates over every stored state. */ + private JsonObject screen(final JsonObject request) throws InterruptedException { + if (store == null) { + return notOpen(); + } + if (!request.has("candidates") || !request.get("candidates").isJsonArray()) { + return error(null, "invalid_request", "screen needs `candidates`, a list of state predicates"); + } + final long budgetMs = request.has("budget_ms") ? request.get("budget_ms").getAsLong() : 60_000L; + final JsonArray candidates = request.getAsJsonArray("candidates"); + final int n = candidates.size(); + final String[] texts = new String[n]; + final OpDefNode[] defs = new OpDefNode[n]; + final String[] errors = new String[n]; + final long[] violations = new long[n]; + final Long[] firstViolation = new Long[n]; + final long[] evaluated = new long[n]; + for (int i = 0; i < n; i++) { + texts[i] = candidates.get(i).getAsString(); + try { + defs[i] = parse(texts[i]); + } catch (final Throwable t) { + errors[i] = t.getMessage() == null ? t.toString() : t.getMessage(); + } + } + if (checkerThread != null && checkerThread.isAlive()) { + checker.suspend(); + } + final long started = System.currentTimeMillis(); + final long[] fps = store.fingerprints(); + int scanned = 0; + boolean budgetHit = false; + for (final long fp : fps) { + if (System.currentTimeMillis() - started > budgetMs) { + budgetHit = true; + break; + } + final TLCState state = store.read(fp); + if (state == null) { + continue; + } + scanned++; + for (int i = 0; i < n; i++) { + if (defs[i] == null || errors[i] != null) { + continue; + } + try { + final IValue v = tool.eval(defs[i].getBody(), Context.Empty, state); + evaluated[i]++; + if (!(v instanceof BoolValue)) { + errors[i] = "not a boolean at fingerprint " + fp + ": " + v; + } else if (!((BoolValue) v).val) { + violations[i]++; + if (firstViolation[i] == null) { + firstViolation[i] = fp; + } + } + } catch (final Throwable t) { + errors[i] = (t.getMessage() == null ? t.toString() : t.getMessage()) + " at fingerprint " + fp; + } + } + } + final JsonObject reply = ok(); + reply.addProperty("stored", fps.length); + reply.addProperty("scanned", scanned); + reply.addProperty("budget_exhausted", budgetHit); + reply.addProperty("duration_ms", System.currentTimeMillis() - started); + final JsonArray results = new JsonArray(); + for (int i = 0; i < n; i++) { + final JsonObject r = new JsonObject(); + r.addProperty("expr", texts[i]); + if (errors[i] != null) { + r.addProperty("verdict", "not_evaluable"); + r.addProperty("error", errors[i]); + } else if (violations[i] > 0) { + r.addProperty("verdict", "violated"); + r.addProperty("violations", violations[i]); + r.addProperty("first_violation_fp", firstViolation[i]); + r.addProperty("first_violation_level", store.level(firstViolation[i])); + } else { + r.addProperty("verdict", budgetHit ? "no_violation_in_scanned" : "holds_on_stored"); + } + r.addProperty("evaluated", evaluated[i]); + results.add(r); + } + reply.add("results", results); + reply.add("messages", recorder.drainMessages()); + return reply; + } + + /** The guard conjuncts that kept transitions from firing, most often first. */ + private JsonObject guardProfile() { + if (store == null) { + return notOpen(); + } + final JsonObject reply = ok(); + final JsonArray rows = new JsonArray(); + for (final GraphStore.Blocked b : store.blocked()) { + final JsonObject o = new JsonObject(); + o.addProperty("action", b.action); + o.addProperty("action_id", b.actionId); + o.addProperty("kind", b.kind); + o.addProperty("location", b.location); + o.addProperty("text", b.text); + o.addProperty("count", b.count); + o.addProperty("example_fp", b.exampleFp); + if (b.exampleBindings != null) { + final JsonObject bindings = new JsonObject(); + b.exampleBindings.forEach(bindings::addProperty); + o.add("example_bindings", bindings); + } + rows.add(o); + } + reply.addProperty("unsatisfied", store.unsatisfied()); + reply.addProperty("excluded", store.excluded()); + reply.add("blocked", rows); + reply.addProperty("note", + "a guard row counts how often the subexpression evaluated false while TLC generated the action's successors, " + + "attributed to the first false conjunct in TLC's evaluation order. Under a disjunction each " + + "false disjunct is counted, even when another disjunct let the action fire. A constraint row " + + "counts successors the action generated that a state or action constraint then excluded"); + if (refreshed) { + reply.addProperty("stale", true); + reply.addProperty("stale_reason", + "the store was refreshed incrementally, and guards are not tallied during a replay; reopen for a fresh profile"); + } + return reply; + } + + void shutdown() { + if (simulator != null && simulatorThread != null && simulatorThread.isAlive()) { + simulator.stop(); + try { + simulatorThread.join(5000); + } catch (final InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + if (checker != null && checkerThread != null && checkerThread.isAlive()) { + checker.stop(); + try { + checkerThread.join(5000); + } catch (final InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + // The stores' files (and a refresh's own metadir) live under the + // spec's directory; nothing reads them once the session ends. + if (checkerThread == null || !checkerThread.isAlive()) { + if (baseStore != null && baseStore != store) { + baseStore.dispose(); + } + if (store != null) { + store.dispose(); + } + baseStore = null; + store = null; + // TLC deletes its run's metadir when the run ends, but on Windows + // a file another process still holds (a virus scanner) makes that + // fail; the session created the directory, so it finishes the job. + if (runMetadir != null) { + if (checker != null) { + for (final tlc2.tool.IWorker w : checker.getWorkers()) { + if (w instanceof tlc2.tool.Worker) { + try { + ((tlc2.tool.Worker) w).closeTrace(); + } catch (final IOException e) { + // Deleting below is what matters. + } + } + } + } + deleteTree(new File(runMetadir)); + final File root = new File(runMetadir).getParentFile(); + final String[] left = root == null ? null : root.list(); + if (left != null && left.length == 0) { + GraphStore.delete(root); + } + } + } + } + + private static void deleteTree(final File f) { + final File[] children = f.listFiles(); + if (children != null) { + for (final File c : children) { + deleteTree(c); + } + } + if (f.exists()) { + GraphStore.delete(f); + } + } + + // ─── replies ──────────────────────────────────────────────────────── + + private static JsonObject ok() { + final JsonObject o = new JsonObject(); + o.addProperty("ok", true); + return o; + } + + private static JsonObject error(final JsonElement id, final String code, final String message) { + final JsonObject o = new JsonObject(); + o.addProperty("ok", false); + o.addProperty("error_code", code); + o.addProperty("error", message); + if (id != null) { + o.add("id", id); + } + return o; + } + + private static String string(final JsonObject o, final String key, final String dflt) { + return o.has(key) && !o.get(key).isJsonNull() ? o.get(key).getAsString() : dflt; + } +} diff --git a/tlatools/org.lamport.tlatools/src/tlc2/tool/AbstractChecker.java b/tlatools/org.lamport.tlatools/src/tlc2/tool/AbstractChecker.java index 8417f57759..b1a1835687 100644 --- a/tlatools/org.lamport.tlatools/src/tlc2/tool/AbstractChecker.java +++ b/tlatools/org.lamport.tlatools/src/tlc2/tool/AbstractChecker.java @@ -61,6 +61,14 @@ public abstract class AbstractChecker protected TLCState predErrState; protected TLCState errState; protected int errorCode; + /** + * Basis: every code {@link #setErrState} or {@link #setError} accepted, in + * order. Under continuation a later code overwrites {@link #errorCode} + * (a next-state evaluation error is followed by the deadlock of the state + * whose expansion it aborted), so the code the run ends on need not name + * every error it hit. Guarded by this checker's monitor. + */ + private final List errorCodes = new ArrayList<>(); protected boolean done; protected boolean keepCallStack; protected final boolean checkDeadlock; @@ -177,15 +185,22 @@ public boolean setErrState(TLCState curState, TLCState succState, boolean keepCa this.predErrState = curState; this.errState = (succState == null) ? curState : succState; this.errorCode = errorCode; + this.errorCodes.add(errorCode); this.done = true; this.keepCallStack = keepCallStack; return true; } + /** Basis: every error code this run set, in order (see {@link #errorCodes}). */ + public synchronized List getErrorCodes() { + return new ArrayList<>(this.errorCodes); + } + public void setError(boolean keepCallStack, int errorCode) { assert Thread.holdsLock(this) : "Caller thread has to hold monitor!"; IdThread.resetCurrentState(); this.errorCode = errorCode; + this.errorCodes.add(errorCode); this.done = true; this.keepCallStack = keepCallStack; } diff --git a/tlatools/org.lamport.tlatools/src/tlc2/tool/Defns.java b/tlatools/org.lamport.tlatools/src/tlc2/tool/Defns.java index 39e463ed8d..035259ffda 100644 --- a/tlatools/org.lamport.tlatools/src/tlc2/tool/Defns.java +++ b/tlatools/org.lamport.tlatools/src/tlc2/tool/Defns.java @@ -30,6 +30,8 @@ public class Defns implements ToolGlobals, Serializable { private int defnIdx; private Object[] table; + /** One past the highest slot any table in this process has handed out. */ + private static int allocated; /** * Constructs the storage of initial size + 32 @@ -87,7 +89,14 @@ public void put(UniqueString key, Object val) int loc = key.getDefnLoc(); if (loc == -1) { - loc = defnIdx++; + // Basis: a name's slot lives on its (process-global) UniqueString, + // so a second parse in one JVM must not hand a new name a slot an + // earlier parse gave another name that this table also holds. + synchronized (Defns.class) { + loc = Math.max(defnIdx, allocated); + defnIdx = loc + 1; + allocated = defnIdx; + } key.setLoc(loc); } if (loc >= this.table.length) diff --git a/tlatools/org.lamport.tlatools/src/tlc2/tool/ModelChecker.java b/tlatools/org.lamport.tlatools/src/tlc2/tool/ModelChecker.java index 5ac60aafcf..12b2fed118 100644 --- a/tlatools/org.lamport.tlatools/src/tlc2/tool/ModelChecker.java +++ b/tlatools/org.lamport.tlatools/src/tlc2/tool/ModelChecker.java @@ -509,7 +509,9 @@ private final boolean doNextCheckInvariants(final ITool tool, final TLCState cur { MP.printError(EC.TLC_INVARIANT_VIOLATED_BEHAVIOR, tool.getInvNames()[k]); - this.trace.printTrace(curState, succState); + if (TLCGlobals.continuationTraceAllowed(tool.getInvNames()[k])) { + this.trace.printTrace(curState, succState); + } return false; } } else { @@ -541,7 +543,9 @@ private final boolean doNextCheckImplied(final ITool tool, final TLCState curSta { MP.printError(EC.TLC_ACTION_PROPERTY_VIOLATED_BEHAVIOR, tool .getImpliedActNames()[k]); - this.trace.printTrace(curState, succState); + if (TLCGlobals.continuationTraceAllowed(tool.getImpliedActNames()[k])) { + this.trace.printTrace(curState, succState); + } return false; } } else { @@ -696,7 +700,7 @@ public final int doPeriodicWork() throws Exception return EC.NO_ERROR; } - if (this.theStateQueue.suspendAll()) + if (this.periodicSuspend()) { // Run liveness checking, if needed: // The ratio set in TLCGlobals defines an upper bound for the @@ -724,7 +728,7 @@ public final int doPeriodicWork() throws Exception checkpoint(); } else { // Just resume worker threads when checkpointing is skipped - this.theStateQueue.resumeAll(); + this.periodicResume(); } } return EC.NO_ERROR; @@ -743,7 +747,7 @@ protected void checkpoint() throws IOException { } // Resume the workers' state-space exploration, which potentially mutates // the intern table and liveness graph. - this.theStateQueue.resumeAll(); + this.periodicResume(); // commit checkpoint: this.theStateQueue.commitChkpt(); this.trace.commitChkpt(); @@ -1057,16 +1061,71 @@ public void stop() { } } + /** + * True while a caller of {@link #suspend()} holds the workers parked. The + * periodic work (checkpoints, liveness checks) suspends and resumes the + * queue on its own; it must not resume workers someone else has parked. + */ + private boolean held = false; + /** True while the periodic work has the workers parked. */ + private boolean periodicParked = false; + /** + * Held by whoever waits in {@code theStateQueue.suspendAll()}. The queue + * wakes a single waiter when the last worker parks, so a second thread + * waiting there at the same time (the periodic work and a caller of + * {@link #suspend()}) would never be woken. Not the checker's monitor: + * workers take that on their way to the barrier. + */ + private final Object suspendLock = new Object(); + + private boolean suspendQueue() { + synchronized (this.suspendLock) { + return this.theStateQueue.suspendAll(); + } + } + + private boolean periodicSuspend() { + synchronized (this) { + this.periodicParked = true; + } + final boolean suspended = this.suspendQueue(); + if (!suspended) { + synchronized (this) { + this.periodicParked = false; + } + } + return suspended; + } + + private void periodicResume() { + synchronized (this) { + this.periodicParked = false; + if (!this.held) { + this.theStateQueue.resumeAll(); + } + } + } + public void suspend() { synchronized (this) { - this.theStateQueue.suspendAll(); + this.held = true; + } + // Basis: wait for the workers outside this monitor. A worker reporting + // a violation (or the end of the run) takes it before it can reach the + // queue's barrier, so waiting while holding it deadlocks. + this.suspendQueue(); + synchronized (this) { this.notifyAll(); } } public void resume() { synchronized (this) { - this.theStateQueue.resumeAll(); + this.held = false; + // Periodic work in progress resumes the workers when it is done. + if (!this.periodicParked) { + this.theStateQueue.resumeAll(); + } this.notifyAll(); } } @@ -1088,6 +1147,11 @@ public TLCStateInfo[] getTraceInfo(final TLCState from, TLCState to) throws IOEx /* (non-Javadoc) * @see tlc2.tool.AbstractChecker#getStateQueueSize() */ + /** The workers of the current run, for their statistics; empty before it. */ + public IWorker[] getWorkers() { + return this.workers == null ? new IWorker[0] : this.workers; + } + @Override public long getStateQueueSize() { return theStateQueue.size(); @@ -1179,6 +1243,10 @@ public Object addElement(final TLCState curState) { liveCheck.addInitState(tool.noDebug(), curState, fp); } } + } else { + // Basis: its invariants are checked below, so a writer + // that sweeps invariants later needs it too. + allStateWriter.writeExcludedInitial(curState); } // Check properties of the state: if (!seen || forceChecks) { diff --git a/tlatools/org.lamport.tlatools/src/tlc2/tool/Simulator.java b/tlatools/org.lamport.tlatools/src/tlc2/tool/Simulator.java index a8af10c0e3..95339f3faa 100644 --- a/tlatools/org.lamport.tlatools/src/tlc2/tool/Simulator.java +++ b/tlatools/org.lamport.tlatools/src/tlc2/tool/Simulator.java @@ -725,6 +725,38 @@ private ActionFlowGraphSnapshot(final Action[] actions, final long[][] actionSta private enum ActionContexts { KEEP, REDUCE } + /** + * The action-pair follow matrix as data: for actions i and j (reduced to + * one vertex per definition), how often j followed i across every worker's + * traces. Populated only when {@link #EXTENDED_STATISTICS} was set at + * class load (the {@code tlc2.tool.Simulator.extendedStatistics} system + * property); otherwise every count is zero, and the caller must not read + * those zeros as observations. + */ + public com.google.gson.JsonObject actionFlowAsJson() { + final com.google.gson.JsonObject out = new com.google.gson.JsonObject(); + out.addProperty("extended_statistics", EXTENDED_STATISTICS); + if (workers == null || workers.isEmpty()) { + return out; + } + final ActionFlowGraphSnapshot snap = getActionFlowGraphSnapshot(ActionContexts.REDUCE); + final com.google.gson.JsonArray names = new com.google.gson.JsonArray(); + for (final Action a : snap.actions) { + names.add(a.getNameOfDefault()); + } + out.add("actions", names); + final com.google.gson.JsonArray rows = new com.google.gson.JsonArray(); + for (final long[] row : snap.actionStats) { + final com.google.gson.JsonArray r = new com.google.gson.JsonArray(); + for (final long v : row) { + r.add(v); + } + rows.add(r); + } + out.add("follows", rows); + return out; + } + private ActionFlowGraphSnapshot getActionFlowGraphSnapshot(final ActionContexts contexts) { // The number of actions is expected to be low (dozens commons and hundreds are // rare). This is why the code below isn't optimized for performance. diff --git a/tlatools/org.lamport.tlatools/src/tlc2/tool/Worker.java b/tlatools/org.lamport.tlatools/src/tlc2/tool/Worker.java index c58bcd0661..8484f02918 100644 --- a/tlatools/org.lamport.tlatools/src/tlc2/tool/Worker.java +++ b/tlatools/org.lamport.tlatools/src/tlc2/tool/Worker.java @@ -286,6 +286,16 @@ final void setLevel(int level) { * cache in BufferedRandomAccessFile hasn't been flushed out. */ + /** + * Close this worker's trace file. A run's cleanup closes the shared trace + * but not the workers' files; a process that exits never notices, but one + * that outlives its run (the resident) must close them before it can + * delete the metadir on Windows. + */ + public final synchronized void closeTrace() throws IOException { + this.raf.close(); + } + public final synchronized void writeState(final TLCState initialState, final long fp) throws IOException { // Write initial state to trace file. this.lastPtr = this.raf.getFilePointer(); @@ -494,7 +504,7 @@ public final Object addElement(final TLCState curState, final Action action, fin public TLCState addUnsatisfiedState(final TLCState curState, final Action action, final TLCState succState, final SemanticNode pred, final Context c) { if (this.allStateWriter.isConstrained()) { - this.allStateWriter.writeState(curState, succState, IStateWriter.IsNotInModel, action, pred); + this.allStateWriter.writeUnsatisfied(curState, action, succState, pred, c); } return succState; } @@ -559,7 +569,9 @@ private final boolean doNextCheckInvariants(final TLCState curState, final TLCSt { MP.printError(EC.TLC_INVARIANT_VIOLATED_BEHAVIOR, this.tool.getInvNames()[k]); - this.tlc.trace.printTrace(curState, succState); + if (TLCGlobals.continuationTraceAllowed(this.tool.getInvNames()[k])) { + this.tlc.trace.printTrace(curState, succState); + } return false; } } else { @@ -591,7 +603,9 @@ private final boolean doNextCheckImplied(final TLCState curState, final TLCState { MP.printError(EC.TLC_ACTION_PROPERTY_VIOLATED_BEHAVIOR, this.tool .getImpliedActNames()[k]); - this.tlc.trace.printTrace(curState, succState); + if (TLCGlobals.continuationTraceAllowed(this.tool.getImpliedActNames()[k])) { + this.tlc.trace.printTrace(curState, succState); + } return false; } } else { diff --git a/tlatools/org.lamport.tlatools/src/tlc2/tool/coverage/CoverageWalk.java b/tlatools/org.lamport.tlatools/src/tlc2/tool/coverage/CoverageWalk.java new file mode 100644 index 0000000000..e5920293b7 --- /dev/null +++ b/tlatools/org.lamport.tlatools/src/tlc2/tool/coverage/CoverageWalk.java @@ -0,0 +1,254 @@ +/******************************************************************************* + * Copyright (c) 2026 Basis Research Institute. All rights reserved. + * + * The MIT License (MIT) + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies + * of the Software, and to permit persons to whom the Software is furnished to do + * so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + ******************************************************************************/ +package tlc2.tool.coverage; + +import java.util.HashSet; +import java.util.Set; + +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; + +import tla2sany.semantic.OpDeclNode; +import tla2sany.semantic.SemanticNode; +import tlc2.TLCGlobals; +import tlc2.tool.Action; +import tlc2.tool.ITool; +import tlc2.tool.coverage.ActionWrapper.Relation; +import tlc2.tool.coverage.OpApplNodeWrapper.Calculate; +import tlc2.util.Vect; + +/** + * The coverage tree as data. + * + *

+ * {@link CostModelCreator#report} walks the same {@link ActionWrapper} and + * {@link OpApplNodeWrapper} tree and prints it, but the printer is lossy + * exactly where a reader wants detail: {@link OpApplNodeWrapper#print} + * skips subtrees whose counts are zero and collapses subtrees whose counts + * agree with their parent. This walk keeps every node with its location, + * primary and secondary counts and whether it is primed, and lists the + * unevaluated subexpressions of every action and invariant separately, so + * a conjunct that never ran is visible rather than elided. It lives in + * this package because the counters are package-private. + */ +public final class CoverageWalk { + + private CoverageWalk() { + } + + /** Whether the counters were armed for this run. */ + public static boolean enabled() { + return TLCGlobals.isCoverageEnabled() || TLCGlobals.Coverage.isEnabled(); + } + + /** + * Per action: name, declaration, relation, how many successor states it + * produced ({@code found}) and how many were new ({@code distinct}); per + * invariant: its evaluations; per variable: the approximate number of + * distinct values seen. Every action's and invariant's subexpression tree + * goes under {@code tree}, and the subexpressions never evaluated under + * {@code unevaluated}. + */ + public static JsonObject walk(final ITool tool) { + final JsonObject out = new JsonObject(); + out.addProperty("enabled", enabled()); + if (!enabled()) { + return out; + } + final JsonArray actions = new JsonArray(); + final Set seen = new HashSet<>(); + for (final Action a : tool.getActions()) { + if (!(a.cm instanceof ActionWrapper)) { + continue; + } + final ActionWrapper w = (ActionWrapper) a.cm; + final JsonObject o = new JsonObject(); + o.addProperty("name", a.getNameOfDefault()); + o.addProperty("id", a.getId()); + o.addProperty("location", a.getLocation()); + o.addProperty("relation", "next"); + // ActionWrapper.report: for NEXT, secondary is the distinct states + // and the eval count the states found. + o.addProperty("found", w.getEvalCount()); + o.addProperty("distinct", w.getSecondary()); + // Actions sharing a predicate share a cost model; the tree is + // listed once and referenced by the others. + if (seen.add(w)) { + o.add("tree", subtree(w)); + o.add("unevaluated", unevaluated(w)); + } else { + o.addProperty("tree_shared", true); + } + actions.add(o); + } + out.add("actions", actions); + + final JsonArray inits = new JsonArray(); + final Vect init = tool.getInitStateSpec(); + for (int i = 0; i < init.size(); i++) { + final Action a = init.elementAt(i); + if (!(a.cm instanceof ActionWrapper)) { + continue; + } + final ActionWrapper w = (ActionWrapper) a.cm; + final JsonObject o = new JsonObject(); + o.addProperty("location", a.getLocation()); + o.addProperty("found", w.getEvalCount()); + o.addProperty("distinct", w.getEvalCount() + w.getSecondary()); + o.add("tree", subtree(w)); + o.add("unevaluated", unevaluated(w)); + inits.add(o); + } + out.add("init", inits); + + final JsonArray invariants = new JsonArray(); + final String[] names = tool.getInvNames(); + final Action[] invs = tool.getInvariants(); + for (int i = 0; i < invs.length; i++) { + final Action a = invs[i]; + if (!(a.cm instanceof ActionWrapper)) { + continue; + } + final ActionWrapper w = (ActionWrapper) a.cm; + final JsonObject o = new JsonObject(); + o.addProperty("name", i < names.length ? names[i] : a.getNameOfDefault()); + o.addProperty("location", a.getLocation()); + o.add("tree", subtree(w)); + o.add("unevaluated", unevaluated(w)); + invariants.add(o); + } + out.add("invariants", invariants); + + final JsonArray variables = new JsonArray(); + for (final OpDeclNode odn : tool.getSpecProcessor().getVariablesNodes()) { + final JsonObject o = new JsonObject(); + o.addProperty("name", odn.getName().toString()); + o.addProperty("location", odn.getLocation().toString()); + final long count = odn.getCountDistinct() == null ? -1 : odn.getCountDistinct().count(); + // -1 means nothing counted (Noop, or before exploration). + if (count >= 0) { + o.addProperty("distinct_values_approx", count); + } else { + o.add("distinct_values_approx", null); + } + variables.add(o); + } + out.add("variables", variables); + return out; + } + + private static JsonArray subtree(final CostModelNode parent) { + final JsonArray out = new JsonArray(); + for (final CostModelNode child : parent.children.values()) { + out.add(node(child)); + } + return out; + } + + private static JsonObject node(final CostModelNode n) { + final JsonObject o = new JsonObject(); + o.addProperty("location", n.getLocation().toString()); + if (n instanceof OpApplNodeWrapper) { + final OpApplNodeWrapper w = (OpApplNodeWrapper) n; + o.addProperty("primary", w.getEvalCount(Calculate.FRESH)); + o.addProperty("secondary", w.getSecondCount(Calculate.FRESH)); + o.addProperty("primed", w.isPrimed()); + final SemanticNode sn = w.getNode(); + if (sn != null) { + o.addProperty("text", tlc2.basis.GraphStore.text(sn)); + } + } else { + o.addProperty("primary", n.getEvalCount()); + o.addProperty("secondary", n.getSecondary()); + } + final JsonArray children = subtree(n); + if (children.size() > 0) { + o.add("children", children); + } + return o; + } + + /** + * The subexpressions under {@code root} whose own evaluation count is + * zero and below which nothing ran, assignments included, listed flat + * with their locations. The inside of an assignment is not listed: its + * primed side is a target, never evaluated. + */ + private static JsonArray unevaluated(final CostModelNode root) { + final JsonArray out = new JsonArray(); + collectUnevaluated(root, out); + return out; + } + + private static void collectUnevaluated(final CostModelNode parent, final JsonArray out) { + if (parent instanceof OpApplNodeWrapper && ((OpApplNodeWrapper) parent).isPrimed()) { + // Under a primed node (x' = e) the left side is an assignment + // target and never evaluated; nothing there is a dead conjunct. + return; + } + for (final CostModelNode child : parent.children.values()) { + if (child instanceof OpApplNodeWrapper) { + final OpApplNodeWrapper w = (OpApplNodeWrapper) child; + // A conjunction's own counter can stay at zero while its + // conjuncts run (the printer collapses such nodes into their + // children), so a node is unevaluated only when nothing below + // it ran either. + // An assignment (x' = e, marked primed) counts when it runs, so + // one whose count is zero with nothing below it run is dead too. + if (w.getEvalCount(Calculate.FRESH) == 0L && !anyEvaluated(w)) { + final JsonObject o = new JsonObject(); + o.addProperty("location", w.getLocation().toString()); + if (w.getNode() != null) { + o.addProperty("text", tlc2.basis.GraphStore.text(w.getNode())); + } + out.add(o); + // Its children are unevaluated too; one entry is enough. + continue; + } + } + collectUnevaluated(child, out); + } + } + + private static boolean anyEvaluated(final CostModelNode node) { + for (final CostModelNode child : node.children.values()) { + if (child instanceof OpApplNodeWrapper) { + final OpApplNodeWrapper w = (OpApplNodeWrapper) child; + if (w.getEvalCount(Calculate.FRESH) > 0L) { + return true; + } + } else if (child.getEvalCount() > 0L) { + return true; + } + if (anyEvaluated(child)) { + return true; + } + } + return false; + } + + /** Which relation an action wrapper records, for callers outside the package. */ + public static boolean is(final CostModel cm, final Relation r) { + return cm instanceof ActionWrapper && ((ActionWrapper) cm).is(r); + } +} diff --git a/tlatools/org.lamport.tlatools/src/tlc2/tool/impl/Tool.java b/tlatools/org.lamport.tlatools/src/tlc2/tool/impl/Tool.java index 704a648b90..e86634902b 100644 --- a/tlatools/org.lamport.tlatools/src/tlc2/tool/impl/Tool.java +++ b/tlatools/org.lamport.tlatools/src/tlc2/tool/impl/Tool.java @@ -898,6 +898,18 @@ public final StateVec getNextStates(Action action, TLCState state) { return getNextStates(action, action.con, state); } + /** + * Basis: {@link #getNextStates(Action, TLCState)} without counting the + * evaluation in the action's coverage, for queries made outside a run + * (the resident's successor queries) that must not change its report. + */ + public final StateVec getNextStatesUnrecorded(final Action action, final TLCState state) { + final StateVec nss = new StateVec(0); + this.getNextStates(action, action.pred, ActionItemList.Empty, action.con, state, + TLCState.Empty.createEmpty().setPredecessor(state).setAction(action), nss, CostModel.DO_NOT_RECORD); + return nss; + } + public final StateVec getNextStates(final Action action, final Context ctx, final TLCState state) { ActionItemList acts = ActionItemList.Empty; TLCState s1 = TLCState.Empty.createEmpty(); @@ -1092,7 +1104,20 @@ protected TLCState processUnsatisfied(final TLCState s0, final Action action, fi final SemanticNode pred, final Context c, final INextStateFunctor nss, final CostModel cm) { return nss.addUnsatisfiedState(s0, action, s1, pred, c); } - + + /** + * Basis: a guard conjunct evaluated false on a path where upstream TLC + * reported nothing. The functor hears of it (a constrained state writer + * tallies it), but unlike {@link #processUnsatisfied} this is not a + * debugger frame, and {@code s1} may still have unassigned variables. + * Returns {@code s1}, which is what these paths returned before. + */ + private TLCState falseGuard(final TLCState s0, final Action action, final TLCState s1, + final SemanticNode pred, final Context c, final INextStateFunctor nss) { + nss.addUnsatisfiedState(s0, action, s1, pred, c); + return s1; + } + /* getNextStatesAppl */ @ExpectInlined @@ -1179,7 +1204,8 @@ private final TLCState getNextStatesApplUsrDefOp(final Action action, final OpAp return this.getNextStates0(action, acts, s0, s1, nss, cm); } } - return s1; + // Basis: a user-defined guard evaluated false; its bindings are not at hand here. + return this.falseGuard(s0, action, s1, pred, Context.Empty, nss); } private final TLCState getNextStatesApplSwitch(final Action action, final OpApplNode pred, final ActionItemList acts, final Context c, final TLCState s0, @@ -1357,7 +1383,7 @@ private final TLCState getNextStatesApplSwitch(final Action action, final OpAppl if (var == null) { Value bval = this.eval(pred, c, s0, s1, EvalControl.Clear, cm); if (!((BoolValue)bval).val) { - return resState; + return this.falseGuard(s0, action, resState, pred, c, nss); } } else { @@ -1371,7 +1397,7 @@ private final TLCState getNextStatesApplSwitch(final Action action, final OpAppl return resState; } else if (!lval.equals(rval)) { - return resState; + return this.falseGuard(s0, action, resState, pred, c, nss); } } return this.getNextStates(action, acts, s0, s1, nss, cm); @@ -1382,7 +1408,7 @@ else if (!lval.equals(rval)) { if (var == null) { Value bval = this.eval(pred, c, s0, s1, EvalControl.Clear, cm); if (!((BoolValue)bval).val) { - return resState; + return this.falseGuard(s0, action, resState, pred, c, nss); } } else { @@ -1414,7 +1440,7 @@ else if (!lval.equals(rval)) { return resState; } else if (!rval.member(lval)) { - return resState; + return this.falseGuard(s0, action, resState, pred, c, nss); } } return this.getNextStates(action, acts, s0, s1, nss, cm); @@ -1426,7 +1452,7 @@ else if (!rval.member(lval)) { if (var == null) { Value bval = this.eval(pred, c, s0, s1, EvalControl.Clear, cm); if (!((BoolValue)bval).val) { - return resState; + return this.falseGuard(s0, action, resState, pred, c, nss); } } else { @@ -1462,7 +1488,7 @@ else if (!rval.member(lval)) { return resState; } else if (!rval.member(lval)) { - return resState; + return this.falseGuard(s0, action, resState, pred, c, nss); } } return this.getNextStates(action, acts, s0, s1, nss, cm); @@ -1545,6 +1571,10 @@ public Object addElement(final TLCState t, final Action a, final TLCState u) { } if (((BoolValue)bval).val) { resState = this.getNextStates(action, acts, s0, s1, nss, cm); + } else { + // Basis: a guard conjunct evaluated false on the general path too, + // not only when every primed variable was already assigned. + return this.falseGuard(s0, action, s1, pred, c, nss); } return resState; } diff --git a/tlatools/org.lamport.tlatools/src/tlc2/util/IStateWriter.java b/tlatools/org.lamport.tlatools/src/tlc2/util/IStateWriter.java index 62d446e943..8f944b0524 100644 --- a/tlatools/org.lamport.tlatools/src/tlc2/util/IStateWriter.java +++ b/tlatools/org.lamport.tlatools/src/tlc2/util/IStateWriter.java @@ -65,6 +65,30 @@ default boolean isSet(int v, int control) { void writeState(TLCState state, TLCState successor, short stateFlags, Action action, SemanticNode pred); + /** + * An initial state that a state constraint excludes from the model. TLC + * checks invariants and state-level properties on it but never explores + * it. The default ignores it, as upstream writers always have. + */ + default void writeExcludedInitial(TLCState state) { + } + + /** + * A guard conjunct {@code pred} of {@code action} evaluated false at + * {@code state} under the bindings in {@code c}, so the transition to + * {@code successor} was not taken. Delivered only to a constrained writer. + * {@code successor} may have unassigned variables (a guard can fail before + * any primed variable is assigned). The default keeps the older behaviour: + * it drops the context and forwards only a fully assigned successor, the + * only kind upstream writers ever received here. + */ + default void writeUnsatisfied(TLCState state, Action action, TLCState successor, SemanticNode pred, + tlc2.util.Context c) { + if (successor != null && successor.allAssigned()) { + writeState(state, successor, IsNotInModel, action, pred); + } + } + void writeState(TLCState state, TLCState successor, short stateFlags, Visualization visualization); void writeState(TLCState state, TLCState successor, BitVector actionChecks, int from, int length, short stateFlags); diff --git a/tlatools/org.lamport.tlatools/src/tlc2/value/ValueOutputStream.java b/tlatools/org.lamport.tlatools/src/tlc2/value/ValueOutputStream.java index ffaa36e9d5..afeccb75b5 100644 --- a/tlatools/org.lamport.tlatools/src/tlc2/value/ValueOutputStream.java +++ b/tlatools/org.lamport.tlatools/src/tlc2/value/ValueOutputStream.java @@ -15,7 +15,7 @@ public final class ValueOutputStream implements IValueOutputStream { private final BufferedDataOutputStream dos; - private final HandleTable handles; + private HandleTable handles; public ValueOutputStream(File file) throws IOException { this(file, TLCGlobals.useGZIP); @@ -70,6 +70,16 @@ public final void writeLong(long x) throws IOException { public final void close() throws IOException { this.dos.close(); } + + /** + * Basis: flush what was written and forget its handles, so the next value + * is encoded on its own and one stream can serialise many states that are + * later decoded separately. + */ + public final void reset() throws IOException { + this.dos.flush(); + this.handles = new HandleTable(); + } /* Precondition: x is a non-negative short. */ @Override diff --git a/tlatools/org.lamport.tlatools/src/tlc2/value/impl/ModelValue.java b/tlatools/org.lamport.tlatools/src/tlc2/value/impl/ModelValue.java index 9077295e58..f3231e0f2d 100644 --- a/tlatools/org.lamport.tlatools/src/tlc2/value/impl/ModelValue.java +++ b/tlatools/org.lamport.tlatools/src/tlc2/value/impl/ModelValue.java @@ -118,6 +118,56 @@ public static Value add(String str) { return mv; } + /** + * Basis: the process-global model-value table, as {@link #snapshot} took + * it. Every parse resets the table ({@link #init}), and serialised values + * name a model value by its index here, so a caller that parses a second + * spec in one process and keeps the first puts the first one's table back. + */ + public static final class Table { + private final int count; + private final Hashtable table; + private final ModelValue[] values; + + private Table(final int count, final Hashtable table, final ModelValue[] values) { + this.count = count; + this.table = table; + this.values = values; + } + + /** + * Whether every model value of this table has the same index in + * {@code later}: a value serialised under this table then decodes to the + * same model value under {@code later}, which may have added more. + */ + public boolean isPrefixOf(final Table later) { + final ModelValue[] mine = this.values == null ? new ModelValue[0] : this.values; + final ModelValue[] theirs = later.values == null ? new ModelValue[0] : later.values; + if (mine.length > theirs.length) { + return false; + } + for (int i = 0; i < mine.length; i++) { + if (mine[i] == null || theirs[i] == null || !mine[i].val.equals(theirs[i].val) + || mine[i].type != theirs[i].type) { + return false; + } + } + return true; + } + } + + /** Basis: the current model-value table, to {@link #restore} later. */ + public static synchronized Table snapshot() { + return new Table(count, mvTable, mvs); + } + + /** Basis: put back a table {@link #snapshot} took; {@link #init} replaced it, it was not mutated. */ + public static synchronized void restore(final Table t) { + count = t.count; + mvTable = t.table; + mvs = t.values; + } + /* Collect all the model values defined thus far. */ public static void setValues() { mvs = new ModelValue[mvTable.size()]; diff --git a/tlatools/org.lamport.tlatools/test-model/basis/Guards.cfg b/tlatools/org.lamport.tlatools/test-model/basis/Guards.cfg new file mode 100644 index 0000000000..e3e8c3d250 --- /dev/null +++ b/tlatools/org.lamport.tlatools/test-model/basis/Guards.cfg @@ -0,0 +1,2 @@ +INIT Init +NEXT Next diff --git a/tlatools/org.lamport.tlatools/test-model/basis/Guards.tla b/tlatools/org.lamport.tlatools/test-model/basis/Guards.tla new file mode 100644 index 0000000000..8545285291 --- /dev/null +++ b/tlatools/org.lamport.tlatools/test-model/basis/Guards.tla @@ -0,0 +1,13 @@ +---- MODULE Guards ---- +EXTENDS Naturals +VARIABLES x, pc + +Init == x = 0 /\ pc = "a" + +\* Each guard is false somewhere before any primed variable is assigned. +A == pc = "a" /\ x' = x + 1 /\ pc' = "b" +B == pc = "b" /\ x \in {1} /\ x' = x + 1 /\ pc' = "c" +C == x < 1 /\ x' = x /\ pc' = "c" + +Next == A \/ B \/ C +==== diff --git a/tlatools/org.lamport.tlatools/test-model/basis/GuardsConstraint.cfg b/tlatools/org.lamport.tlatools/test-model/basis/GuardsConstraint.cfg new file mode 100644 index 0000000000..070a9d7a22 --- /dev/null +++ b/tlatools/org.lamport.tlatools/test-model/basis/GuardsConstraint.cfg @@ -0,0 +1,3 @@ +INIT Init +NEXT Next +CONSTRAINT Small diff --git a/tlatools/org.lamport.tlatools/test-model/basis/GuardsConstraint.tla b/tlatools/org.lamport.tlatools/test-model/basis/GuardsConstraint.tla new file mode 100644 index 0000000000..cd1331dca7 --- /dev/null +++ b/tlatools/org.lamport.tlatools/test-model/basis/GuardsConstraint.tla @@ -0,0 +1,13 @@ +---- MODULE GuardsConstraint ---- +EXTENDS Naturals +VARIABLES x + +Init == x = 0 + +A == x < 5 /\ x' = x + 1 +B == x = 10 /\ x' = 0 + +Next == A \/ B + +Small == x < 3 +==== diff --git a/tlatools/org.lamport.tlatools/test/tlc2/TLCGlobalsContinuationTraceTest.java b/tlatools/org.lamport.tlatools/test/tlc2/TLCGlobalsContinuationTraceTest.java new file mode 100644 index 0000000000..013f0878ff --- /dev/null +++ b/tlatools/org.lamport.tlatools/test/tlc2/TLCGlobalsContinuationTraceTest.java @@ -0,0 +1,64 @@ +/******************************************************************************* + * Copyright (c) 2026 Basis Research Institute. All rights reserved. + * + * The MIT License (MIT) + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies + * of the Software, and to permit persons to whom the Software is furnished to do + * so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + ******************************************************************************/ +package tlc2; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import org.junit.After; +import org.junit.Test; + +public class TLCGlobalsContinuationTraceTest { + + @After + public void restore() { + TLCGlobals.continuationTraceLimit = -1; + TLCGlobals.resetContinuationTraces(); + } + + @Test + public void unlimitedByDefault() { + TLCGlobals.continuationTraceLimit = -1; + for (int i = 0; i < 10; i++) { + assertTrue(TLCGlobals.continuationTraceAllowed("Inv")); + } + } + + @Test + public void capIsPerProperty() { + TLCGlobals.continuationTraceLimit = 2; + assertTrue(TLCGlobals.continuationTraceAllowed("Inv")); + assertTrue(TLCGlobals.continuationTraceAllowed("Inv")); + assertFalse(TLCGlobals.continuationTraceAllowed("Inv")); + assertTrue(TLCGlobals.continuationTraceAllowed("Other")); + assertTrue(TLCGlobals.continuationTraceAllowed(null)); + TLCGlobals.resetContinuationTraces(); + assertTrue(TLCGlobals.continuationTraceAllowed("Inv")); + } + + @Test + public void zeroPrintsNone() { + TLCGlobals.continuationTraceLimit = 0; + assertFalse(TLCGlobals.continuationTraceAllowed("Inv")); + } +} diff --git a/tlatools/org.lamport.tlatools/test/tlc2/basis/GraphStoreConstraintTest.java b/tlatools/org.lamport.tlatools/test/tlc2/basis/GraphStoreConstraintTest.java new file mode 100644 index 0000000000..f9db08303b --- /dev/null +++ b/tlatools/org.lamport.tlatools/test/tlc2/basis/GraphStoreConstraintTest.java @@ -0,0 +1,108 @@ +/******************************************************************************* + * Copyright (c) 2026 Basis Research Institute. All rights reserved. + * + * The MIT License (MIT) + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies + * of the Software, and to permit persons to whom the Software is furnished to do + * so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + ******************************************************************************/ +package tlc2.basis; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +import java.io.IOException; +import java.nio.file.Files; +import java.util.HashMap; +import java.util.Map; + +import org.junit.Test; + +import tlc2.output.EC; +import tlc2.output.EC.ExitStatus; +import tlc2.tool.liveness.ModelCheckerTestCase; +import tlc2.util.IStateWriter; + +/** + * A successor a state constraint excludes is tallied as a constraint row, not + * as a guard of the action that generated it, and is counted apart from the + * false guards. + */ +public class GraphStoreConstraintTest extends ModelCheckerTestCase { + + public GraphStoreConstraintTest() { + super("GuardsConstraint", "basis", new String[] { "-deadlock" }, ExitStatus.SUCCESS); + } + + private GraphStore store; + + @Override + protected boolean doDump() { + return false; + } + + @Override + protected boolean doCoverage() { + return false; + } + + @Override + protected int getNumberOfThreads() { + return 1; + } + + @Override + protected IStateWriter getStateWriter(final IStateWriter sw) { + try { + store = new GraphStore(Files.createTempDirectory("graphstore").toString()); + return store; + } catch (IOException e) { + fail(e.getMessage()); + return null; + } + } + + @Test + public void testSpec() { + assertTrue(recorder.recorded(EC.TLC_FINISHED)); + assertFalse(recorder.recorded(EC.GENERAL)); + + // 0 -A-> 1 -A-> 2; A's step from 2 to 3 is excluded by Small. + assertEquals(3, store.states()); + assertEquals(2, store.edges()); + + final Map rows = new HashMap<>(); + for (final GraphStore.Blocked b : store.blocked()) { + rows.put(b.kind + " " + b.action, b); + } + assertEquals(rows.toString(), 2, rows.size()); + // B's guard fails at 0, 1 and 2. + final GraphStore.Blocked guard = rows.get("guard B"); + assertNotNull(rows.keySet().toString(), guard); + assertEquals("x=10", guard.text); + assertEquals(3, guard.count); + // The constraint dropped one successor A generated. + final GraphStore.Blocked constraint = rows.get("constraint A"); + assertNotNull(rows.keySet().toString(), constraint); + assertEquals(1, constraint.count); + assertEquals(3, store.unsatisfied()); + assertEquals(1, store.excluded()); + } +} diff --git a/tlatools/org.lamport.tlatools/test/tlc2/basis/GraphStoreGuardTest.java b/tlatools/org.lamport.tlatools/test/tlc2/basis/GraphStoreGuardTest.java new file mode 100644 index 0000000000..348ea27d73 --- /dev/null +++ b/tlatools/org.lamport.tlatools/test/tlc2/basis/GraphStoreGuardTest.java @@ -0,0 +1,122 @@ +/******************************************************************************* + * Copyright (c) 2026 Basis Research Institute. All rights reserved. + * + * The MIT License (MIT) + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies + * of the Software, and to permit persons to whom the Software is furnished to do + * so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + ******************************************************************************/ +package tlc2.basis; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +import java.io.IOException; +import java.nio.file.Files; +import java.util.HashMap; +import java.util.Map; + +import org.junit.Test; + +import tlc2.output.EC; +import tlc2.output.EC.ExitStatus; +import tlc2.tool.liveness.ModelCheckerTestCase; +import tlc2.util.IStateWriter; + +/** + * The store on a real run: every reached state, every edge, and a tally for + * every guard that evaluated false, including equality and membership guards + * on unprimed variables, which TLC evaluates before any primed variable is + * assigned. + */ +public class GraphStoreGuardTest extends ModelCheckerTestCase { + + public GraphStoreGuardTest() { + super("Guards", "basis", new String[] { "-deadlock" }, ExitStatus.SUCCESS); + } + + private GraphStore store; + + @Override + protected boolean doDump() { + return false; + } + + @Override + protected boolean doCoverage() { + return false; + } + + @Override + protected int getNumberOfThreads() { + return 1; + } + + @Override + protected IStateWriter getStateWriter(final IStateWriter sw) { + try { + store = new GraphStore(Files.createTempDirectory("graphstore").toString()); + return store; + } catch (IOException e) { + fail(e.getMessage()); + return null; + } + } + + @Test + public void testSpec() { + assertTrue(recorder.recorded(EC.TLC_FINISHED)); + assertFalse(recorder.recorded(EC.GENERAL)); + assertTrue(recorder.recordedWithStringValues(EC.TLC_STATS, "5", "4", "0")); + + // (0,a) -A-> (1,b) -B-> (2,c); (0,a) -C-> (0,c) -C-> (0,c). + assertEquals(4, store.states()); + assertEquals(1, store.initialStates()); + assertEquals(4, store.edges()); + + final Map counts = new HashMap<>(); + for (final GraphStore.Blocked b : store.blocked()) { + assertEquals("guard", b.kind); + counts.put(b.action + ": " + b.text, b.count); + } + // A's guard fails at (1,b), (0,c), (2,c); B's at (0,a), (0,c), (2,c); + // C's at (1,b), (2,c). + assertEquals(Long.valueOf(3), counts.get("A: pc=\"a\"")); + assertEquals(Long.valueOf(3), counts.get("B: pc=\"b\"")); + assertEquals(Long.valueOf(2), counts.get("C: x<1")); + assertEquals(3, counts.size()); + assertEquals(8, store.unsatisfied()); + assertEquals(0, store.excluded()); + + // Every stored state reads back, and the deepest one's path is the + // three-state behaviour through A and B. + long deepest = 0; + for (final long fp : store.fingerprints()) { + assertNotNull(store.read(fp)); + if (store.level(fp) == 3) { + deepest = fp; + } + } + final long[][] path = store.pathTo(deepest); + assertEquals(3, path.length); + assertEquals("A", store.action((int) path[1][1]).getNameOfDefault()); + assertEquals("B", store.action((int) path[2][1]).getNameOfDefault()); + } +} diff --git a/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentConcurrentSuspendTest.java b/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentConcurrentSuspendTest.java new file mode 100644 index 0000000000..1dd7f5df57 --- /dev/null +++ b/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentConcurrentSuspendTest.java @@ -0,0 +1,103 @@ +/******************************************************************************* + * Copyright (c) 2026 Basis Research Institute. All rights reserved. + * + * The MIT License (MIT) + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies + * of the Software, and to permit persons to whom the Software is furnished to do + * so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + ******************************************************************************/ +package tlc2.basis; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; + +import org.junit.Test; + +import com.google.gson.JsonObject; + +import tlc2.tool.ModelChecker; + +/** + * The resident's pause and TLC's periodic work (a liveness check or a + * checkpoint) can both wait for the workers to park at once. The queue + * wakes a single waiter when the last worker parks, so both must not wait + * there together: with every state's expansion slow, the two start waiting + * while workers are mid-expansion, and both must return. + */ +public class ResidentConcurrentSuspendTest { + + @Test(timeout = 120_000) + public void testPauseAndPeriodicWorkSuspendTogether() throws Exception { + final ResidentHarness h = new ResidentHarness(); + h.write("S.cfg", "INIT Init\nNEXT Next\n"); + h.write("S.tla", "---- MODULE S ----\n" // + + "EXTENDS Naturals, FiniteSets\n" // + + "VARIABLES x, y\n" // + + "Heavy == Cardinality({s \\in SUBSET (1..16) : 1 \\in s}) > x\n" // + + "Init == x = 0 /\\ y = 0\n" // + + "A == Heavy /\\ x < 4 /\\ x' = x + 1 /\\ UNCHANGED y\n" // + + "B == Heavy /\\ y < 4 /\\ y' = y + 1 /\\ UNCHANGED x\n" // + + "Next == A \\/ B\n" // + + "====\n"); + h.ok("{\"command\":\"open\",\"spec\":\"" + h.spec("S") + + "\",\"workers\":2,\"deadlock\":false,\"coverage\":false,\"store\":false}"); + final JsonObject paused = h.ok("{\"command\":\"check\",\"budget_states\":3}"); + assertFalse(paused.get("finished").getAsBoolean()); + + final Field f = Resident.class.getDeclaredField("checker"); + f.setAccessible(true); + final ModelChecker checker = (ModelChecker) f.get(h.resident); + final Method periodicSuspend = ModelChecker.class.getDeclaredMethod("periodicSuspend"); + final Method periodicResume = ModelChecker.class.getDeclaredMethod("periodicResume"); + periodicSuspend.setAccessible(true); + periodicResume.setAccessible(true); + + final ExecutorService pool = Executors.newFixedThreadPool(2); + try { + for (int round = 0; round < 3; round++) { + checker.resume(); + // Let the workers get into their (slow) expansions. + Thread.sleep(30); + final Future periodic = pool.submit(() -> periodicSuspend.invoke(checker)); + final Future pause = pool.submit(() -> { + checker.suspend(); + return null; + }); + periodic.get(30, TimeUnit.SECONDS); + pause.get(30, TimeUnit.SECONDS); + // The periodic work ends; the resident's pause still holds. + periodicResume.invoke(checker); + } + } finally { + pool.shutdownNow(); + } + + final JsonObject done = h.ok("{\"command\":\"check\"}"); + assertTrue(done.get("finished").getAsBoolean()); + assertEquals("ok", done.get("verdict").getAsString()); + assertEquals(25, done.getAsJsonObject("stats").get("distinct").getAsLong()); + h.resident.shutdown(); + } +} diff --git a/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentContinuationBudgetTest.java b/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentContinuationBudgetTest.java new file mode 100644 index 0000000000..bf7897aa7a --- /dev/null +++ b/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentContinuationBudgetTest.java @@ -0,0 +1,79 @@ +/******************************************************************************* + * Copyright (c) 2026 Basis Research Institute. All rights reserved. + * + * The MIT License (MIT) + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies + * of the Software, and to permit persons to whom the Software is furnished to do + * so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + ******************************************************************************/ +package tlc2.basis; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +import com.google.gson.JsonObject; + +/** + * A budgeted check that continues past violations, on several workers, over + * a spec whose invariant fails on most states. Pausing must not deadlock + * with workers reporting violations, and reports past the per-property + * trace cap must be counted rather than kept as messages. + */ +public class ResidentContinuationBudgetTest { + + @Test(timeout = 120_000) + public void testBudgetedContinuation() throws Exception { + final ResidentHarness h = new ResidentHarness(); + h.write("C.cfg", "INIT Init\nNEXT Next\nINVARIANT Inv\n"); + h.write("C.tla", "---- MODULE C ----\n" // + + "EXTENDS Naturals\n" // + + "VARIABLES a, b, c\n" // + + "N == 20\n" // + + "Init == a = 0 /\\ b = 0 /\\ c = 0\n" // + + "A == a < N-1 /\\ a' = a + 1 /\\ UNCHANGED <>\n" // + + "B == b < N-1 /\\ b' = b + 1 /\\ UNCHANGED <>\n" // + + "C == c < N-1 /\\ c' = c + 1 /\\ UNCHANGED <>\n" // + + "Next == A \\/ B \\/ C\n" // + + "Inv == a < 3\n" // + + "====\n"); + h.ok("{\"command\":\"open\",\"spec\":\"" + h.spec("C") + "\",\"workers\":4,\"deadlock\":false,\"store\":false}"); + + JsonObject check = null; + long messages = 0; + int pauses = 0; + for (int i = 0; i < 10_000; i++) { + check = h.ok("{\"command\":\"check\",\"continue\":true,\"budget_ms\":5}"); + messages += check.getAsJsonArray("messages").size(); + if (check.get("finished").getAsBoolean()) { + break; + } + pauses++; + } + assertTrue(check.toString(), check.get("finished").getAsBoolean()); + assertTrue("the budget never paused the run", pauses > 0); + assertEquals("invariant_violated", check.get("verdict").getAsString()); + assertEquals(8000, check.getAsJsonObject("stats").get("distinct").getAsLong()); + // Every state with a >= 3 violates Inv: 17 * 20 * 20 reports, one traced. + final JsonObject inv = check.getAsJsonArray("invariants").get(0).getAsJsonObject(); + assertEquals(6800, inv.get("reports").getAsLong()); + assertEquals(6799, check.getAsJsonObject("untraced_reports").get("Inv").getAsLong()); + assertTrue("reports past the cap were kept as messages: " + messages, messages < 200); + h.resident.shutdown(); + } +} diff --git a/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentContinuationExcludedTest.java b/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentContinuationExcludedTest.java new file mode 100644 index 0000000000..be646f2f24 --- /dev/null +++ b/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentContinuationExcludedTest.java @@ -0,0 +1,62 @@ +/******************************************************************************* + * Copyright (c) 2026 Basis Research Institute. All rights reserved. + * + * The MIT License (MIT) + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies + * of the Software, and to permit persons to whom the Software is furnished to do + * so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + ******************************************************************************/ +package tlc2.basis; + +import static org.junit.Assert.assertEquals; +import static tlc2.basis.ResidentContinuationInvariantsTest.verdict; + +import org.junit.Test; + +import com.google.gson.JsonObject; + +/** + * Under continuation TLC stops checking invariants on a state at the first + * one it violates, excluded successors included. The sweep that decides the + * skipped invariants must reach the excluded successors as TLC does. + */ +public class ResidentContinuationExcludedTest { + + @Test + public void testSkippedInvariantOnExcludedState() throws Exception { + final ResidentHarness h = new ResidentHarness(); + h.write("CX.cfg", "INIT Init\nNEXT Next\nINVARIANT InvA\nINVARIANT InvB\nCONSTRAINT Cons\n"); + h.write("CX.tla", "---- MODULE CX ----\n" // + + "EXTENDS Naturals\n" // + + "VARIABLE x\n" // + + "Init == x = 0\n" // + + "Next == x' = x + 1\n" // + + "InvA == x < 2\n" // + + "InvB == x # 3\n" // + + "Cons == x < 3\n" // + + "====\n"); + h.ok("{\"command\":\"open\",\"spec\":\"" + h.spec("CX") + "\",\"workers\":1,\"deadlock\":false}"); + final JsonObject check = h.ok("{\"command\":\"check\",\"continue\":true}"); + assertEquals(check.toString(), "violated", verdict(check, "InvA").get("verdict").getAsString()); + // x = 3 is excluded and fails InvA first, so TLC never checks InvB there. + final JsonObject b = verdict(check, "InvB"); + assertEquals(check.toString(), "violated", b.get("verdict").getAsString()); + assertEquals(check.toString(), "store", b.get("source").getAsString()); + assertEquals(check.toString(), 4, b.get("level").getAsInt()); + h.resident.shutdown(); + } +} diff --git a/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentContinuationInvariantsNoStoreTest.java b/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentContinuationInvariantsNoStoreTest.java new file mode 100644 index 0000000000..a2b148b95f --- /dev/null +++ b/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentContinuationInvariantsNoStoreTest.java @@ -0,0 +1,53 @@ +/******************************************************************************* + * Copyright (c) 2026 Basis Research Institute. All rights reserved. + * + * The MIT License (MIT) + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies + * of the Software, and to permit persons to whom the Software is furnished to do + * so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + ******************************************************************************/ +package tlc2.basis; + +import static org.junit.Assert.assertEquals; + +import org.junit.Test; + +import com.google.gson.JsonObject; + +/** + * Without a store the states TLC skipped under continuation cannot be + * revisited: an invariant TLC never reported gets no verdict either way. + */ +public class ResidentContinuationInvariantsNoStoreTest { + + @Test + public void testSkippedInvariantIsNotEvaluated() throws Exception { + final ResidentHarness h = new ResidentHarness(); + h.write("CI.cfg", ResidentContinuationInvariantsTest.CFG); + h.write("CI.tla", ResidentContinuationInvariantsTest.SPEC); + h.ok("{\"command\":\"open\",\"spec\":\"" + h.spec("CI") + + "\",\"workers\":1,\"deadlock\":false,\"store\":false}"); + final JsonObject check = h.ok("{\"command\":\"check\",\"continue\":true}"); + assertEquals(check.toString(), "violated", + ResidentContinuationInvariantsTest.verdict(check, "InvA").get("verdict").getAsString()); + assertEquals(check.toString(), "not_evaluated", + ResidentContinuationInvariantsTest.verdict(check, "InvB").get("verdict").getAsString()); + assertEquals(check.toString(), "not_evaluated", + ResidentContinuationInvariantsTest.verdict(check, "InvC").get("verdict").getAsString()); + h.resident.shutdown(); + } +} diff --git a/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentContinuationInvariantsTest.java b/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentContinuationInvariantsTest.java new file mode 100644 index 0000000000..dc31f4ed2a --- /dev/null +++ b/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentContinuationInvariantsTest.java @@ -0,0 +1,81 @@ +/******************************************************************************* + * Copyright (c) 2026 Basis Research Institute. All rights reserved. + * + * The MIT License (MIT) + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies + * of the Software, and to permit persons to whom the Software is furnished to do + * so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + ******************************************************************************/ +package tlc2.basis; + +import static org.junit.Assert.assertEquals; + +import org.junit.Test; + +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; + +/** + * Under continuation TLC checks no further invariant on a state once one + * fails there. InvB fails exactly where InvA does, so TLC never reports it; + * the resident must still find it violated, over the store, and the + * registers must say the exhausted run found violations. + */ +public class ResidentContinuationInvariantsTest { + + static final String SPEC = "---- MODULE CI ----\n" // + + "EXTENDS Naturals\n" // + + "VARIABLE x\n" // + + "Init == x = 0\n" // + + "Next == x < 5 /\\ x' = x + 1\n" // + + "InvA == x # 3\n" // + + "InvB == x # 3\n" // + + "InvC == x < 10\n" // + + "====\n"; + static final String CFG = "INIT Init\nNEXT Next\nINVARIANT InvA\nINVARIANT InvB\nINVARIANT InvC\n"; + + static JsonObject verdict(final JsonObject check, final String name) { + final JsonArray invs = check.getAsJsonArray("invariants"); + for (int i = 0; i < invs.size(); i++) { + final JsonObject v = invs.get(i).getAsJsonObject(); + if (name.equals(v.get("name").getAsString())) { + return v; + } + } + throw new AssertionError("no verdict for " + name + " in " + check); + } + + @Test + public void testSkippedInvariantIsSwept() throws Exception { + final ResidentHarness h = new ResidentHarness(); + h.write("CI.cfg", CFG); + h.write("CI.tla", SPEC); + h.ok("{\"command\":\"open\",\"spec\":\"" + h.spec("CI") + "\",\"workers\":1,\"deadlock\":false}"); + final JsonObject check = h.ok("{\"command\":\"check\",\"continue\":true}"); + assertEquals(check.toString(), "violated", verdict(check, "InvA").get("verdict").getAsString()); + final JsonObject b = verdict(check, "InvB"); + assertEquals(check.toString(), "violated", b.get("verdict").getAsString()); + assertEquals(1, b.get("reports").getAsLong()); + assertEquals(4, b.get("level").getAsInt()); + assertEquals("store", b.get("source").getAsString()); + assertEquals(check.toString(), "no_violation_found", verdict(check, "InvC").get("verdict").getAsString()); + + final JsonObject registers = h.ok("{\"command\":\"registers\"}").getAsJsonObject("registers"); + assertEquals(registers.toString(), "exhausted_with_violations", registers.get("stopped_by").getAsString()); + h.resident.shutdown(); + } +} diff --git a/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentContinuationTest.java b/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentContinuationTest.java new file mode 100644 index 0000000000..81ff93ea35 --- /dev/null +++ b/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentContinuationTest.java @@ -0,0 +1,76 @@ +/******************************************************************************* + * Copyright (c) 2026 Basis Research Institute. All rights reserved. + * + * The MIT License (MIT) + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies + * of the Software, and to permit persons to whom the Software is furnished to do + * so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + ******************************************************************************/ +package tlc2.basis; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +import com.google.gson.JsonObject; + +/** + * A run that stopped at its first violation did not explore the whole + * graph, and asking for continuation after it ended must not make it look + * as if it had: the next refresh must restart rather than replay a partial + * graph. + */ +public class ResidentContinuationTest { + + private static String spec(final String inv) { + return "---- MODULE K ----\n" // + + "EXTENDS Integers\n" // + + "VARIABLE x\n" // + + "Init == x = 0\n" // + + "A == x \\in 0..4 /\\ x' = x + 1\n" // + + "Next == A\n" // + + "Inv == " + inv + "\n" // + + "====\n"; + } + + @Test + public void testContinuationAfterTheRunEnded() throws Exception { + final ResidentHarness h = new ResidentHarness(); + h.write("K.cfg", "INIT Init\nNEXT Next\nINVARIANT Inv\n"); + h.write("K.tla", spec("x < 3")); + h.ok("{\"command\":\"open\",\"spec\":\"" + h.spec("K") + "\",\"workers\":1,\"deadlock\":false}"); + JsonObject check = h.ok("{\"command\":\"check\"}"); + assertEquals("invariant_violated", check.get("verdict").getAsString()); + assertFalse(check.get("continuation").getAsBoolean()); + + // The run has ended; this explores nothing and changes nothing. + check = h.ok("{\"command\":\"check\",\"continue\":true}"); + assertFalse(check.toString(), check.get("continuation").getAsBoolean()); + final JsonObject registers = h.ok("{\"command\":\"registers\"}").getAsJsonObject("registers"); + assertFalse(registers.toString(), registers.get("exhausted").getAsBoolean()); + assertEquals("violation", registers.get("stopped_by").getAsString()); + + // x = 5 is reachable but was never generated: no replay can know. + h.write("K.tla", spec("x /= 5")); + final JsonObject r = h.ok("{\"command\":\"refresh\"}"); + assertEquals(r.toString(), "full", r.get("mode").getAsString()); + assertTrue(r.get("restart_required").getAsBoolean()); + h.resident.shutdown(); + } +} diff --git a/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentContinuationToggleTest.java b/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentContinuationToggleTest.java new file mode 100644 index 0000000000..fdaba127a6 --- /dev/null +++ b/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentContinuationToggleTest.java @@ -0,0 +1,73 @@ +/******************************************************************************* + * Copyright (c) 2026 Basis Research Institute. All rights reserved. + * + * The MIT License (MIT) + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies + * of the Software, and to permit persons to whom the Software is furnished to do + * so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + ******************************************************************************/ +package tlc2.basis; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static tlc2.basis.ResidentContinuationInvariantsTest.verdict; + +import org.junit.Test; + +import com.google.gson.JsonObject; + +/** + * A run paused under continuation after InvA was reported, then resumed + * without it. TLC skipped InvB on the state where InvA failed while it + * continued, so InvB's silence is still no verdict: the resident must sweep + * it over the store, not call it clean because the last call did not continue. + */ +public class ResidentContinuationToggleTest { + + static final String SPEC = "---- MODULE CT ----\n" // + + "EXTENDS Naturals\n" // + + "VARIABLE x\n" // + + "Init == x = 0\n" // + // Enough states that the six-state budget below takes effect before + // the run ends: the budget is polled, not enforced per state. + + "Next == x < 200000 /\\ x' = x + 1\n" // + + "InvA == x # 3\n" // + + "InvB == x # 3\n" // + + "====\n"; + static final String CFG = "INIT Init\nNEXT Next\nINVARIANT InvA\nINVARIANT InvB\n"; + + @Test + public void testResumeWithoutContinuationStillSweeps() throws Exception { + final ResidentHarness h = new ResidentHarness(); + h.write("CT.cfg", CFG); + h.write("CT.tla", SPEC); + h.ok("{\"command\":\"open\",\"spec\":\"" + h.spec("CT") + "\",\"workers\":1,\"deadlock\":false}"); + final JsonObject paused = h.ok("{\"command\":\"check\",\"continue\":true,\"budget_states\":6}"); + assertFalse(paused.toString(), paused.get("finished").getAsBoolean()); + assertEquals(paused.toString(), "violated", verdict(paused, "InvA").get("verdict").getAsString()); + + final JsonObject done = h.ok("{\"command\":\"check\",\"continue\":false}"); + assertTrue(done.toString(), done.get("finished").getAsBoolean()); + assertEquals(done.toString(), "violated", verdict(done, "InvA").get("verdict").getAsString()); + final JsonObject b = verdict(done, "InvB"); + assertEquals(done.toString(), "violated", b.get("verdict").getAsString()); + assertEquals("store", b.get("source").getAsString()); + assertEquals(4, b.get("level").getAsInt()); + h.resident.shutdown(); + } +} diff --git a/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentCoverageTest.java b/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentCoverageTest.java new file mode 100644 index 0000000000..100ac735f2 --- /dev/null +++ b/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentCoverageTest.java @@ -0,0 +1,99 @@ +/******************************************************************************* + * Copyright (c) 2026 Basis Research Institute. All rights reserved. + * + * The MIT License (MIT) + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies + * of the Software, and to permit persons to whom the Software is furnished to do + * so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + ******************************************************************************/ +package tlc2.basis; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +import java.util.HashMap; +import java.util.Map; + +import org.junit.Test; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; + +/** + * Coverage as data: a conjunct no evaluation reached is listed under + * {@code unevaluated}, a primed conjunct (an assignment) is not, and an + * action whose guards all ran lists nothing. After an incremental refresh + * the coverage is still the run's, marked stale, not an empty tree. + */ +public class ResidentCoverageTest { + + private static String spec(final int dLimit) { + return "---- MODULE V ----\n" // + + "EXTENDS Naturals\n" // + + "VARIABLES x\n" // + + "Init == x = 0\n" // + + "A == x < 3 /\\ x' = x + 1\n" // + + "D == x > " + dLimit + " /\\ x * 2 > 25 /\\ x' = 0\n" // + + "Next == A \\/ D\n" // + + "====\n"; + } + + @Test + public void testUnevaluated() throws Exception { + final ResidentHarness h = new ResidentHarness(); + h.write("V.cfg", "INIT Init\nNEXT Next\n"); + h.write("V.tla", spec(10)); + h.ok("{\"command\":\"open\",\"spec\":\"" + h.spec("V") + "\",\"workers\":1,\"deadlock\":false}"); + h.ok("{\"command\":\"check\"}"); + final JsonObject coverage = h.ok("{\"command\":\"coverage\"}").getAsJsonObject("coverage"); + assertTrue(coverage.get("enabled").getAsBoolean()); + final Map actions = new HashMap<>(); + for (final JsonElement e : coverage.getAsJsonArray("actions")) { + actions.put(e.getAsJsonObject().get("name").getAsString(), e.getAsJsonObject()); + } + assertEquals(3, actions.get("A").get("found").getAsLong()); + assertEquals(0, actions.get("A").getAsJsonArray("unevaluated").size()); + assertEquals(0, actions.get("D").get("found").getAsLong()); + // Past D's first guard nothing ran: the second guard, and the + // assignment, whose only evaluated part would be its primed side. + final JsonArray dead = actions.get("D").getAsJsonArray("unevaluated"); + assertEquals(dead.toString(), 2, dead.size()); + assertEquals("x*2>25", dead.get(0).getAsJsonObject().get("text").getAsString()); + assertEquals("x'=0", dead.get(1).getAsJsonObject().get("text").getAsString()); + assertTrue(h.ok("{\"command\":\"coverage\"}").get("stale") == null); + + // Queries over the store evaluate outside the run and leave its + // coverage as it was. + final String fp = h.ok("{\"command\":\"screen\",\"candidates\":[\"x # 1\"]}").getAsJsonArray("results") + .get(0).getAsJsonObject().get("first_violation_fp").getAsString(); + for (int i = 0; i < 3; i++) { + h.ok("{\"command\":\"neighbours\",\"fp\":\"" + fp + "\"}"); + h.ok("{\"command\":\"eval\",\"fp\":\"" + fp + "\",\"expr\":\"x + 1\"}"); + } + assertEquals(coverage.toString(), h.ok("{\"command\":\"coverage\"}").getAsJsonObject("coverage").toString()); + + // A refresh replaces the tool with one no checker ran; coverage stays + // the run's, and says it is stale. + h.write("V.tla", spec(11)); + assertEquals("incremental", h.ok("{\"command\":\"refresh\"}").get("mode").getAsString()); + final JsonObject after = h.ok("{\"command\":\"coverage\"}"); + assertTrue(after.toString(), after.get("stale").getAsBoolean()); + assertEquals(after.toString(), 2, after.getAsJsonObject("coverage").getAsJsonArray("actions").size()); + h.resident.shutdown(); + } +} diff --git a/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentEvaluationFailedContinueTest.java b/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentEvaluationFailedContinueTest.java new file mode 100644 index 0000000000..1e8f88dcc1 --- /dev/null +++ b/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentEvaluationFailedContinueTest.java @@ -0,0 +1,81 @@ +/******************************************************************************* + * Copyright (c) 2026 Basis Research Institute. All rights reserved. + * + * The MIT License (MIT) + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies + * of the Software, and to permit persons to whom the Software is furnished to do + * so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + ******************************************************************************/ +package tlc2.basis; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; + +/** + * An invariant that fails to evaluate is not violated: its verdict says it + * could not be evaluated, and the run that stopped on the error did not + * explore the whole graph. Under continuation TLC prints the failing + * behaviour a second time when it rebuilds the call stack; the trace holds + * it once. + */ +public class ResidentEvaluationFailedContinueTest { + + @Test + public void testEvaluationFailure() throws Exception { + final ResidentHarness h = new ResidentHarness(); + h.write("E.cfg", "INIT Init\nNEXT Next\nINVARIANT Inv\n"); + h.write("E.tla", "---- MODULE E ----\n" // + + "EXTENDS Naturals\n" // + + "VARIABLE x\n" // + + "Init == x = 0\n" // + + "Next == x < 5 /\\ x' = x + 1\n" // + + "Inv == IF x = 3 THEN (1 \\div 0) = 1 ELSE TRUE\n" // + + "====\n"); + h.ok("{\"command\":\"open\",\"spec\":\"" + h.spec("E") + "\",\"workers\":1,\"deadlock\":false}"); + final JsonObject check = h.ok("{\"command\":\"check\",\"continue\":true}"); + assertTrue(check.toString(), check.get("finished").getAsBoolean()); + assertEquals(check.toString(), "evaluation_failed", check.get("verdict").getAsString()); + + final JsonArray invariants = check.getAsJsonArray("invariants"); + assertEquals(1, invariants.size()); + final JsonObject inv = invariants.get(0).getAsJsonObject(); + assertEquals(inv.toString(), "not_evaluable", inv.get("verdict").getAsString()); + assertFalse(inv.toString(), inv.has("reports")); + assertTrue(inv.toString(), inv.has("error")); + // x = 3 is the fourth state of the behaviour. + assertEquals(inv.toString(), 4, inv.get("level").getAsInt()); + + final JsonObject trace = check.getAsJsonObject("trace"); + assertEquals(trace.toString(), 4, trace.get("length").getAsInt()); + final JsonArray states = trace.getAsJsonArray("states"); + for (int i = 0; i < states.size(); i++) { + assertEquals(states.toString(), i + 1, states.get(i).getAsJsonObject().get("ordinal").getAsInt()); + } + + // x = 4 and x = 5 were never explored. + final JsonObject registers = h.ok("{\"command\":\"registers\"}").getAsJsonObject("registers"); + assertFalse(registers.toString(), registers.get("exhausted").getAsBoolean()); + assertEquals(registers.toString(), "error", registers.get("stopped_by").getAsString()); + h.resident.shutdown(); + } +} diff --git a/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentEvaluationFailedTest.java b/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentEvaluationFailedTest.java new file mode 100644 index 0000000000..2c9f8ca5bb --- /dev/null +++ b/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentEvaluationFailedTest.java @@ -0,0 +1,79 @@ +/******************************************************************************* + * Copyright (c) 2026 Basis Research Institute. All rights reserved. + * + * The MIT License (MIT) + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies + * of the Software, and to permit persons to whom the Software is furnished to do + * so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + ******************************************************************************/ +package tlc2.basis; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; + +/** + * An invariant that fails to evaluate is not violated: its verdict says it + * could not be evaluated, and the run that stopped on the error did not + * explore the whole graph. + */ +public class ResidentEvaluationFailedTest { + + @Test + public void testEvaluationFailure() throws Exception { + final ResidentHarness h = new ResidentHarness(); + h.write("E.cfg", "INIT Init\nNEXT Next\nINVARIANT Inv\n"); + h.write("E.tla", "---- MODULE E ----\n" // + + "EXTENDS Naturals\n" // + + "VARIABLE x\n" // + + "Init == x = 0\n" // + + "Next == x < 5 /\\ x' = x + 1\n" // + + "Inv == IF x = 3 THEN (1 \\div 0) = 1 ELSE TRUE\n" // + + "====\n"); + h.ok("{\"command\":\"open\",\"spec\":\"" + h.spec("E") + "\",\"workers\":1,\"deadlock\":false}"); + final JsonObject check = h.ok("{\"command\":\"check\",\"continue\":false}"); + assertTrue(check.toString(), check.get("finished").getAsBoolean()); + assertEquals(check.toString(), "evaluation_failed", check.get("verdict").getAsString()); + + final JsonArray invariants = check.getAsJsonArray("invariants"); + assertEquals(1, invariants.size()); + final JsonObject inv = invariants.get(0).getAsJsonObject(); + assertEquals(inv.toString(), "not_evaluable", inv.get("verdict").getAsString()); + assertFalse(inv.toString(), inv.has("reports")); + assertTrue(inv.toString(), inv.has("error")); + // x = 3 is the fourth state of the behaviour. + assertEquals(inv.toString(), 4, inv.get("level").getAsInt()); + + final JsonObject trace = check.getAsJsonObject("trace"); + assertEquals(trace.toString(), 4, trace.get("length").getAsInt()); + final JsonArray states = trace.getAsJsonArray("states"); + for (int i = 0; i < states.size(); i++) { + assertEquals(states.toString(), i + 1, states.get(i).getAsJsonObject().get("ordinal").getAsInt()); + } + + // x = 4 and x = 5 were never explored. + final JsonObject registers = h.ok("{\"command\":\"registers\"}").getAsJsonObject("registers"); + assertFalse(registers.toString(), registers.get("exhausted").getAsBoolean()); + assertEquals(registers.toString(), "error", registers.get("stopped_by").getAsString()); + h.resident.shutdown(); + } +} diff --git a/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentHarness.java b/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentHarness.java new file mode 100644 index 0000000000..09bd63e203 --- /dev/null +++ b/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentHarness.java @@ -0,0 +1,79 @@ +/******************************************************************************* + * Copyright (c) 2026 Basis Research Institute. All rights reserved. + * + * The MIT License (MIT) + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies + * of the Software, and to permit persons to whom the Software is furnished to do + * so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + ******************************************************************************/ +package tlc2.basis; + +import static org.junit.Assert.assertTrue; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; + +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; + +/** + * Drives a {@link Resident} in-process, one request object at a time, over a + * spec written into a fresh directory. TLC's statics allow one resident per + * JVM; the build forks one per test class. + */ +final class ResidentHarness { + + final Path dir; + final Resident resident = Resident.install(); + + ResidentHarness() throws IOException { + dir = Files.createTempDirectory("resident"); + } + + void write(final String name, final String text) throws IOException { + Files.write(dir.resolve(name), text.getBytes(StandardCharsets.UTF_8)); + } + + /** + * The module's path, escaped for a JSON string literal: tests splice it + * into request text, and a Windows path's backslashes are escapes there. + */ + String spec(final String module) { + return dir.resolve(module + ".tla").toString().replace("\\", "\\\\"); + } + + /** Serve {@code json}; the reply must be ok. */ + JsonObject ok(final String json) throws Exception { + final JsonObject reply = call(json); + assertTrue(reply.toString(), reply.get("ok").getAsBoolean()); + return reply; + } + + JsonObject call(final String json) throws Exception { + return resident.serve(JsonParser.parseString(json).getAsJsonObject()); + } + + static long storeStates(final JsonObject reply) { + return reply.getAsJsonObject("store").get("states").getAsLong(); + } + + static long storeEdges(final JsonObject reply) { + return reply.getAsJsonObject("store").get("edges").getAsLong(); + } +} diff --git a/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentInitBudgetTest.java b/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentInitBudgetTest.java new file mode 100644 index 0000000000..5e571dec9e --- /dev/null +++ b/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentInitBudgetTest.java @@ -0,0 +1,61 @@ +/******************************************************************************* + * Copyright (c) 2026 Basis Research Institute. All rights reserved. + * + * The MIT License (MIT) + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies + * of the Software, and to permit persons to whom the Software is furnished to do + * so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + ******************************************************************************/ +package tlc2.basis; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +import com.google.gson.JsonObject; + +/** + * A budget that runs out while the initial states are generated, in a run + * that then ends there (an initial state violates the invariant): no worker + * ever starts, and the budget's suspend must not wait for one. + */ +public class ResidentInitBudgetTest { + + @Test(timeout = 120_000) + public void testBudgetDuringInit() throws Exception { + final ResidentHarness h = new ResidentHarness(); + h.write("I.cfg", "INIT Init\nNEXT Next\nINVARIANT Inv\n"); + h.write("I.tla", "---- MODULE I ----\n" // + + "EXTENDS Naturals\n" // + + "VARIABLES x\n" // + // Under 65536 initial states: the build caps direct memory so + // that OffHeapDiskFPSet evicts there, and an eviction during + // init trips DiskFPSet's checkFile assertion upstream too. + + "Init == x \\in 1..50000\n" // + + "Next == x' = x\n" // + + "Inv == x < 49000\n" // + + "====\n"); + h.ok("{\"command\":\"open\",\"spec\":\"" + h.spec("I") + "\",\"workers\":2}"); + // Returns rather than hanging, whether or not the run has ended yet. + h.ok("{\"command\":\"check\",\"budget_ms\":1}"); + final JsonObject done = h.ok("{\"command\":\"check\"}"); + assertTrue(done.toString(), done.get("finished").getAsBoolean()); + assertEquals(done.toString(), "invariant_violated", done.get("verdict").getAsString()); + h.resident.shutdown(); + } +} diff --git a/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentInitialViolationContinueTest.java b/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentInitialViolationContinueTest.java new file mode 100644 index 0000000000..fe4f707a1d --- /dev/null +++ b/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentInitialViolationContinueTest.java @@ -0,0 +1,78 @@ +/******************************************************************************* + * Copyright (c) 2026 Basis Research Institute. All rights reserved. + * + * The MIT License (MIT) + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies + * of the Software, and to permit persons to whom the Software is furnished to do + * so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + ******************************************************************************/ +package tlc2.basis; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +import java.util.HashSet; +import java.util.Set; + +import org.junit.Test; + +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; + +/** + * Under continuation every initial state that violates an invariant is + * listed with its state, those a state constraint excludes included, next to + * the traces of violations reached later. + */ +public class ResidentInitialViolationContinueTest { + + @Test + public void testInitialViolationsAreListed() throws Exception { + final ResidentHarness h = new ResidentHarness(); + h.write("IC.cfg", "INIT Init\nNEXT Next\nINVARIANT Small\nCONSTRAINT Bound\n"); + h.write("IC.tla", "---- MODULE IC ----\n" // + + "EXTENDS Naturals\n" // + + "VARIABLE x\n" // + + "Init == x \\in {0, 9, 20}\n" // + + "Next == x < 9 /\\ x' = x + 1\n" // + + "Small == x < 8\n" // + + "Bound == x < 15\n" // + + "====\n"); + h.ok("{\"command\":\"open\",\"spec\":\"" + h.spec("IC") + "\",\"workers\":1,\"deadlock\":false}"); + final JsonObject check = h.ok("{\"command\":\"check\",\"continue\":true}"); + assertTrue(check.toString(), check.get("finished").getAsBoolean()); + final Set initial = new HashSet<>(); + int behaviours = 0; + for (final JsonElement e : check.getAsJsonArray("traces")) { + final JsonObject t = e.getAsJsonObject(); + if (t.get("length").getAsInt() == 1) { + final JsonObject s = t.getAsJsonArray("states").get(0).getAsJsonObject(); + assertEquals(check.toString(), "", s.get("action").getAsString()); + initial.add(s.getAsJsonObject("vars").get("x").getAsInt()); + } else { + behaviours++; + } + } + // 9 is in the model, 20 excluded by the constraint; 8 is reached from 0. + assertEquals(check.toString(), Set.of(9, 20), initial); + assertEquals(check.toString(), 1, behaviours); + final JsonObject small = check.getAsJsonArray("invariants").get(0).getAsJsonObject(); + assertEquals(check.toString(), "violated", small.get("verdict").getAsString()); + assertEquals(check.toString(), 1, small.get("level").getAsInt()); + h.resident.shutdown(); + } +} diff --git a/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentInitialViolationNoStoreTest.java b/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentInitialViolationNoStoreTest.java new file mode 100644 index 0000000000..a347016bdc --- /dev/null +++ b/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentInitialViolationNoStoreTest.java @@ -0,0 +1,58 @@ +/******************************************************************************* + * Copyright (c) 2026 Basis Research Institute. All rights reserved. + * + * The MIT License (MIT) + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies + * of the Software, and to permit persons to whom the Software is furnished to do + * so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + ******************************************************************************/ +package tlc2.basis; + +import static org.junit.Assert.assertEquals; + +import org.junit.Test; + +import com.google.gson.JsonObject; + +/** + * Without a store an initial state's violation still has its one-state + * trace, the state kept as TLC printed it. + */ +public class ResidentInitialViolationNoStoreTest { + + @Test + public void testInitialViolationKeepsPrintedState() throws Exception { + final ResidentHarness h = new ResidentHarness(); + h.write("IN.cfg", "INIT Init\nNEXT Next\nINVARIANT Small\n"); + h.write("IN.tla", "---- MODULE IN ----\n" // + + "EXTENDS Naturals\n" // + + "VARIABLE x\n" // + + "Init == x \\in {0, 9}\n" // + + "Next == x < 9 /\\ x' = x + 1\n" // + + "Small == x < 8\n" // + + "====\n"); + h.ok("{\"command\":\"open\",\"spec\":\"" + h.spec("IN") + "\",\"workers\":1,\"deadlock\":false,\"store\":false}"); + final JsonObject check = h.ok("{\"command\":\"check\"}"); + final JsonObject trace = check.getAsJsonObject("trace"); + assertEquals(check.toString(), 1, trace.get("length").getAsInt()); + final JsonObject state = trace.getAsJsonArray("states").get(0).getAsJsonObject(); + assertEquals(check.toString(), "x = 9", state.get("tla").getAsString().replace("/\\", "").trim()); + final JsonObject small = check.getAsJsonArray("invariants").get(0).getAsJsonObject(); + assertEquals(check.toString(), 1, small.get("level").getAsInt()); + h.resident.shutdown(); + } +} diff --git a/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentInitialViolationTest.java b/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentInitialViolationTest.java new file mode 100644 index 0000000000..ebb99e7224 --- /dev/null +++ b/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentInitialViolationTest.java @@ -0,0 +1,64 @@ +/******************************************************************************* + * Copyright (c) 2026 Basis Research Institute. All rights reserved. + * + * The MIT License (MIT) + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies + * of the Software, and to permit persons to whom the Software is furnished to do + * so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + ******************************************************************************/ +package tlc2.basis; + +import static org.junit.Assert.assertEquals; + +import org.junit.Test; + +import com.google.gson.JsonObject; + +/** + * An invariant false in an initial state has a one-state counterexample: TLC + * prints that state only into its report, and the resident hands it on as a + * typed state from the store, at level 1. + */ +public class ResidentInitialViolationTest { + + @Test + public void testInitialInvariantViolationHasItsState() throws Exception { + final ResidentHarness h = new ResidentHarness(); + h.write("IV.cfg", "INIT Init\nNEXT Next\nINVARIANT Small\n"); + h.write("IV.tla", "---- MODULE IV ----\n" // + + "EXTENDS Naturals\n" // + + "VARIABLE x\n" // + + "Init == x \\in {0, 9}\n" // + + "Next == x < 9 /\\ x' = x + 1\n" // + + "Small == x < 8\n" // + + "====\n"); + h.ok("{\"command\":\"open\",\"spec\":\"" + h.spec("IV") + "\",\"workers\":1,\"deadlock\":false}"); + final JsonObject check = h.ok("{\"command\":\"check\"}"); + assertEquals(check.toString(), "invariant_violated", check.get("verdict").getAsString()); + final JsonObject trace = check.getAsJsonObject("trace"); + assertEquals(check.toString(), 1, trace.get("length").getAsInt()); + final JsonObject state = trace.getAsJsonArray("states").get(0).getAsJsonObject(); + assertEquals(check.toString(), 9, state.getAsJsonObject("vars").get("x").getAsInt()); + assertEquals(check.toString(), "", state.get("action").getAsString()); + assertEquals(check.toString(), 1, check.getAsJsonArray("traces").size()); + final JsonObject small = check.getAsJsonArray("invariants").get(0).getAsJsonObject(); + assertEquals(check.toString(), "violated", small.get("verdict").getAsString()); + assertEquals(check.toString(), 1, small.get("level").getAsInt()); + assertEquals(check.toString(), "", small.get("action").getAsString()); + h.resident.shutdown(); + } +} diff --git a/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentNextStateErrorContinue.java b/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentNextStateErrorContinue.java new file mode 100644 index 0000000000..22cff14bc0 --- /dev/null +++ b/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentNextStateErrorContinue.java @@ -0,0 +1,101 @@ +/******************************************************************************* + * Copyright (c) 2026 Basis Research Institute. All rights reserved. + * + * The MIT License (MIT) + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies + * of the Software, and to permit persons to whom the Software is furnished to do + * so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + ******************************************************************************/ +package tlc2.basis; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; + +/** + * Under continuation TLC keeps going after the next-state relation fails to + * evaluate, and with deadlock checking on it then reports the state whose + * expansion the error aborted as a deadlock, which overwrites the error's + * code. The run is still an error: B's successor of x = 2, which violates + * Inv, was never generated, so Inv has no verdict and the store is not the + * whole graph. The earlier violation of Inv2 keeps its own two-state trace + * though the error's behaviour is printed after it. Shared by + * {@link ResidentNextStateErrorContinueTest} (deadlock checked) and + * {@link ResidentNextStateErrorContinueNoDeadlockTest}. + */ +final class ResidentNextStateErrorContinue { + + private static final String SPEC = "---- MODULE F ----\n" // + + "EXTENDS Naturals\n" // + + "VARIABLE x\n" // + + "Init == x = 0\n" // + + "A == x < 5 /\\ x' = IF x = 2 THEN 1 \\div 0 ELSE x + 1\n" // + + "B == x = 2 /\\ x' = 10\n" // + + "Next == A \\/ B\n" // + + "Inv == x < 10\n" // + + "Inv2 == x # 1\n" // + + "====\n"; + + private ResidentNextStateErrorContinue() { + } + + /** One resident per JVM: each deadlock setting runs in its own test class. */ + static void run(final boolean deadlock) throws Exception { + final ResidentHarness h = new ResidentHarness(); + h.write("F.cfg", "INIT Init\nNEXT Next\nINVARIANT Inv\nINVARIANT Inv2\n"); + h.write("F.tla", SPEC); + h.ok("{\"command\":\"open\",\"spec\":\"" + h.spec("F") + "\",\"workers\":1,\"deadlock\":" + deadlock + "}"); + final JsonObject check = h.ok("{\"command\":\"check\",\"continue\":true}"); + assertTrue(check.toString(), check.get("finished").getAsBoolean()); + assertEquals(check.toString(), "error", check.get("verdict").getAsString()); + assertTrue(check.toString(), check.has("error_code")); + + final JsonArray invariants = check.getAsJsonArray("invariants"); + final JsonObject inv = invariants.get(0).getAsJsonObject(); + assertEquals(inv.toString(), "Inv", inv.get("name").getAsString()); + assertEquals(inv.toString(), "not_evaluated", inv.get("verdict").getAsString()); + final JsonObject inv2 = invariants.get(1).getAsJsonObject(); + assertEquals(inv2.toString(), "violated", inv2.get("verdict").getAsString()); + // x = 1 is the second state of the behaviour. + assertEquals(inv2.toString(), 2, inv2.get("level").getAsInt()); + + JsonObject violation = null; + for (final JsonElement t : check.getAsJsonArray("traces")) { + final JsonElement property = t.getAsJsonObject().get("property"); + if (property != null && !property.isJsonNull() && "Inv2".equals(property.getAsString())) { + violation = t.getAsJsonObject(); + } + } + assertTrue(check.toString(), violation != null); + assertEquals(violation.toString(), 2, violation.get("length").getAsInt()); + + final JsonObject registers = h.ok("{\"command\":\"registers\"}").getAsJsonObject("registers"); + assertFalse(registers.toString(), registers.get("exhausted").getAsBoolean()); + assertEquals(registers.toString(), "error", registers.get("stopped_by").getAsString()); + + // Nothing to replay from: the store is not the whole graph. + h.write("F.tla", SPEC.replace("Inv2 == x # 1", "Inv2 == x # 2")); + final JsonObject refresh = h.ok("{\"command\":\"refresh\"}"); + assertEquals(refresh.toString(), "full", refresh.get("mode").getAsString()); + assertTrue(refresh.toString(), refresh.get("restart_required").getAsBoolean()); + h.resident.shutdown(); + } +} diff --git a/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentNextStateErrorContinueNoDeadlockTest.java b/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentNextStateErrorContinueNoDeadlockTest.java new file mode 100644 index 0000000000..ad58cdfb77 --- /dev/null +++ b/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentNextStateErrorContinueNoDeadlockTest.java @@ -0,0 +1,34 @@ +/******************************************************************************* + * Copyright (c) 2026 Basis Research Institute. All rights reserved. + * + * The MIT License (MIT) + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies + * of the Software, and to permit persons to whom the Software is furnished to do + * so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + ******************************************************************************/ +package tlc2.basis; + +import org.junit.Test; + +/** {@link ResidentNextStateErrorContinue} with deadlock checking off: the run ends on the error's code. */ +public class ResidentNextStateErrorContinueNoDeadlockTest { + + @Test + public void testNextStateError() throws Exception { + ResidentNextStateErrorContinue.run(false); + } +} diff --git a/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentNextStateErrorContinueTest.java b/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentNextStateErrorContinueTest.java new file mode 100644 index 0000000000..1751ae741f --- /dev/null +++ b/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentNextStateErrorContinueTest.java @@ -0,0 +1,34 @@ +/******************************************************************************* + * Copyright (c) 2026 Basis Research Institute. All rights reserved. + * + * The MIT License (MIT) + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies + * of the Software, and to permit persons to whom the Software is furnished to do + * so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + ******************************************************************************/ +package tlc2.basis; + +import org.junit.Test; + +/** {@link ResidentNextStateErrorContinue} with deadlock checking on: the error is overwritten by a deadlock. */ +public class ResidentNextStateErrorContinueTest { + + @Test + public void testNextStateError() throws Exception { + ResidentNextStateErrorContinue.run(true); + } +} diff --git a/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentPauseTest.java b/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentPauseTest.java new file mode 100644 index 0000000000..d65217861e --- /dev/null +++ b/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentPauseTest.java @@ -0,0 +1,78 @@ +/******************************************************************************* + * Copyright (c) 2026 Basis Research Institute. All rights reserved. + * + * The MIT License (MIT) + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies + * of the Software, and to permit persons to whom the Software is furnished to do + * so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + ******************************************************************************/ +package tlc2.basis; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +import com.google.gson.JsonObject; + +import tlc2.TLCGlobals; + +/** + * A budget pause must hold while TLC does its periodic work. With a + * checkpoint due on every wake-up, each suspend wakes the checker's main + * thread into a checkpoint, which suspends and resumes the queue on its own; + * that resume must not restart workers the resident parked. + */ +public class ResidentPauseTest { + + @Test + public void testPauseHoldsThroughCheckpoints() throws Exception { + final ResidentHarness h = new ResidentHarness(); + h.write("P.cfg", "INIT Init\nNEXT Next\n"); + h.write("P.tla", "---- MODULE P ----\n" // + + "EXTENDS Naturals\n" // + + "VARIABLES a, b, c\n" // + + "N == 30\n" // + + "Init == a = 0 /\\ b = 0 /\\ c = 0\n" // + + "A == a < N-1 /\\ a' = a + 1 /\\ UNCHANGED <>\n" // + + "B == b < N-1 /\\ b' = b + 1 /\\ UNCHANGED <>\n" // + + "C == c < N-1 /\\ c' = c + 1 /\\ UNCHANGED <>\n" // + + "Next == A \\/ B \\/ C\n" // + + "====\n"); + h.ok("{\"command\":\"open\",\"spec\":\"" + h.spec("P") + "\",\"workers\":2,\"deadlock\":false,\"coverage\":false}"); + TLCGlobals.chkptDuration = 1; + + final JsonObject paused = h.ok("{\"command\":\"check\",\"budget_states\":50}"); + assertFalse(paused.get("finished").getAsBoolean()); + final long before = h.ok("{\"command\":\"stats\"}").getAsJsonObject("stats").get("distinct").getAsLong(); + for (int i = 0; i < 5; i++) { + // Each suspend wakes the main thread into a checkpoint. + h.ok("{\"command\":\"screen\",\"candidates\":[\"a < 100\"]}"); + Thread.sleep(300); + } + final JsonObject stats = h.ok("{\"command\":\"stats\"}").getAsJsonObject("stats"); + assertEquals("workers ran while paused", before, stats.get("distinct").getAsLong()); + assertTrue(stats.get("running").getAsBoolean()); + + final JsonObject done = h.ok("{\"command\":\"check\"}"); + assertTrue(done.get("finished").getAsBoolean()); + assertEquals("ok", done.get("verdict").getAsString()); + assertEquals(27000, done.getAsJsonObject("stats").get("distinct").getAsLong()); + h.resident.shutdown(); + } +} diff --git a/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentPropertyInitialTest.java b/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentPropertyInitialTest.java new file mode 100644 index 0000000000..81d4bd7f97 --- /dev/null +++ b/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentPropertyInitialTest.java @@ -0,0 +1,63 @@ +/******************************************************************************* + * Copyright (c) 2026 Basis Research Institute. All rights reserved. + * + * The MIT License (MIT) + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies + * of the Software, and to permit persons to whom the Software is furnished to do + * so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + ******************************************************************************/ +package tlc2.basis; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; + +import org.junit.Test; + +import com.google.gson.JsonObject; + +/** + * A PROPERTY that is false in an initial state is a violation, named as + * such, not an error. + */ +public class ResidentPropertyInitialTest { + + @Test + public void testInitialPropertyViolation() throws Exception { + final ResidentHarness h = new ResidentHarness(); + h.write("PI.cfg", "INIT Init\nNEXT Next\nPROPERTY Prop\n"); + h.write("PI.tla", "---- MODULE PI ----\n" // + + "EXTENDS Naturals\n" // + + "VARIABLE x\n" // + + "Init == x = 0\n" // + + "Next == x < 2 /\\ x' = x + 1\n" // + + "Prop == x = 1\n" // + + "====\n"); + h.ok("{\"command\":\"open\",\"spec\":\"" + h.spec("PI") + "\",\"workers\":1,\"deadlock\":false}"); + final JsonObject check = h.ok("{\"command\":\"check\"}"); + assertEquals(check.toString(), "property_violated", check.get("verdict").getAsString()); + assertEquals(check.toString(), "Prop", check.get("violated").getAsString()); + // TLC prints the state only into its report; the trace carries it. + final JsonObject trace = check.getAsJsonObject("trace"); + assertEquals(check.toString(), 1, trace.get("length").getAsInt()); + assertEquals(check.toString(), 0, trace.getAsJsonArray("states").get(0).getAsJsonObject() + .getAsJsonObject("vars").get("x").getAsInt()); + final JsonObject registers = h.ok("{\"command\":\"registers\"}").getAsJsonObject("registers"); + assertFalse(registers.get("exhausted").getAsBoolean()); + assertEquals(registers.toString(), "violation", registers.get("stopped_by").getAsString()); + h.resident.shutdown(); + } +} diff --git a/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentRefreshAssumptionTest.java b/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentRefreshAssumptionTest.java new file mode 100644 index 0000000000..d16fb75e93 --- /dev/null +++ b/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentRefreshAssumptionTest.java @@ -0,0 +1,109 @@ +/******************************************************************************* + * Copyright (c) 2026 Basis Research Institute. All rights reserved. + * + * The MIT License (MIT) + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies + * of the Software, and to permit persons to whom the Software is furnished to do + * so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + ******************************************************************************/ +package tlc2.basis; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static tlc2.basis.ResidentHarness.storeStates; + +import org.junit.Test; + +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; + +/** + * A refresh checks what TLC checks before and at the initial states: an + * edited ASSUME that is false leaves the edit unadopted (TLC would explore + * nothing), and a state-level PROPERTY, which TLC checks on the initial + * states only, gets a verdict over the stored initial states. + */ +public class ResidentRefreshAssumptionTest { + + private static String spec(final String assume, final String pos, final int lim) { + return "---- MODULE A ----\n" // + + "EXTENDS Naturals\n" // + + "CONSTANT N\n" // + + "VARIABLE x\n" // + + "ASSUME " + assume + "\n" // + + "Init == x = 0\n" // + + "Inc == x < " + lim + " /\\ x' = x + 1\n" // + + "Dec == x > 0 /\\ x' = x - 1\n" // + + "Next == Inc \\/ Dec\n" // + + "TypeOK == x \\in 0..N\n" // + + "Pos == " + pos + "\n" // + + "====\n"; + } + + private static JsonObject named(final JsonArray verdicts, final String name) { + for (int i = 0; i < verdicts.size(); i++) { + final JsonObject v = verdicts.get(i).getAsJsonObject(); + if (name.equals(v.get("name").getAsString())) { + return v; + } + } + throw new AssertionError("no verdict for " + name + " in " + verdicts); + } + + @Test + public void testAssumptionsAndImpliedInits() throws Exception { + final ResidentHarness h = new ResidentHarness(); + h.write("A.cfg", "CONSTANT N = 3\nINIT Init\nNEXT Next\nINVARIANT TypeOK\nPROPERTY Pos\n"); + h.write("A.tla", spec("N > 0", "x >= 0", 3)); + final JsonObject open = h + .ok("{\"command\":\"open\",\"spec\":\"" + h.spec("A") + "\",\"workers\":1,\"deadlock\":false}"); + assertTrue(open.toString(), open.getAsJsonObject("catalogue").getAsJsonArray("implied_inits").toString() + .contains("Pos")); + assertEquals("ok", h.ok("{\"command\":\"check\"}").get("verdict").getAsString()); + + // A false assumption: not adopted, the store stays, and it still serves. + h.write("A.tla", spec("N > 100", "x >= 0", 2)); + JsonObject r = h.ok("{\"command\":\"refresh\"}"); + assertFalse(r.toString(), r.get("adopted").getAsBoolean()); + assertEquals(r.toString(), "assumption_false", r.get("verdict").getAsString()); + assertEquals(4, storeStates(r)); + final JsonObject screen = h.ok("{\"command\":\"screen\",\"candidates\":[\"x < 3\"]}"); + assertEquals("violated", + screen.getAsJsonArray("results").get(0).getAsJsonObject().get("verdict").getAsString()); + + // The assumption fixed and the state-level property broken: adopted, + // and the property is violated in the initial state, as TLC reports it. + h.write("A.tla", spec("N > 0", "x >= 1", 2)); + r = h.ok("{\"command\":\"refresh\"}"); + assertEquals(r.toString(), "incremental", r.get("mode").getAsString()); + assertTrue(r.toString(), r.get("adopted").getAsBoolean()); + assertEquals("hold", r.get("assumptions").getAsString()); + assertEquals(3, storeStates(r)); + assertEquals(r.toString(), "violated_initially", + named(r.getAsJsonArray("implied_inits"), "Pos").get("verdict").getAsString()); + assertEquals("no_violation_found", named(r.getAsJsonArray("invariants"), "TypeOK").get("verdict").getAsString()); + + // And fixed again. + h.write("A.tla", spec("N > 0", "x >= 0", 2)); + r = h.ok("{\"command\":\"refresh\"}"); + assertTrue(r.toString(), r.get("adopted").getAsBoolean()); + assertEquals("no_violation_found", + named(r.getAsJsonArray("implied_inits"), "Pos").get("verdict").getAsString()); + h.resident.shutdown(); + } +} diff --git a/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentRefreshDefinitionsTest.java b/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentRefreshDefinitionsTest.java new file mode 100644 index 0000000000..00823a346c --- /dev/null +++ b/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentRefreshDefinitionsTest.java @@ -0,0 +1,81 @@ +/******************************************************************************* + * Copyright (c) 2026 Basis Research Institute. All rights reserved. + * + * The MIT License (MIT) + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies + * of the Software, and to permit persons to whom the Software is furnished to do + * so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + ******************************************************************************/ +package tlc2.basis; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +import com.google.gson.JsonObject; + +/** + * Parsing the edited spec in the same JVM must resolve the config's names to + * the same definitions a fresh run would. Definition slots live on + * process-global strings, so names the edit adds, before and after the ones + * the config names, must not take the slots of INIT, NEXT or INVARIANT. + */ +public class ResidentRefreshDefinitionsTest { + + private static String spec(final int before, final int after) { + final StringBuilder sb = new StringBuilder("---- MODULE D ----\nVARIABLES x, y\n"); + for (int i = 0; i < before; i++) { + sb.append("Pb").append(i).append(" == ").append(i).append('\n'); + } + sb.append("Init == x = \"a\" /\\ y = FALSE\n") // + .append("A == x = \"a\" /\\ x' = \"b\" /\\ y' = TRUE\n") // + .append("B == x = \"b\" /\\ x' = \"c\" /\\ y' = FALSE\n") // + .append("Next == A \\/ B\n") // + .append("Inv == y \\in BOOLEAN\n"); + for (int i = 0; i < after; i++) { + sb.append("Pa").append(i).append(" == y = FALSE\n"); + } + return sb.append("====\n").toString(); + } + + @Test + public void testAddedDefinitions() throws Exception { + final ResidentHarness h = new ResidentHarness(); + h.write("D.cfg", "INIT Init\nNEXT Next\nINVARIANT Inv\n"); + h.write("D.tla", spec(0, 0)); + h.ok("{\"command\":\"open\",\"spec\":\"" + h.spec("D") + "\",\"workers\":1,\"deadlock\":false}"); + assertEquals("ok", h.ok("{\"command\":\"check\"}").get("verdict").getAsString()); + + // Before the fix, (8, 1) resolved INVARIANT Inv to Pa0 and (6, 3) + // resolved NEXT to Pa1; (4, 1) and (0, 5) took Init's slot. + final int[][] edits = { { 8, 1 }, { 6, 3 }, { 4, 1 }, { 0, 5 }, { 12, 12 } }; + for (final int[] e : edits) { + h.write("D.tla", spec(e[0], e[1])); + final JsonObject r = h.ok("{\"command\":\"refresh\"}"); + assertEquals(r.toString(), "incremental", r.get("mode").getAsString()); + assertTrue(r.toString(), r.get("complete").getAsBoolean()); + final JsonObject diff = r.getAsJsonObject("diff"); + assertEquals(r.toString(), "[\"A\",\"B\"]", diff.getAsJsonArray("unchanged").toString()); + assertEquals(r.toString(), 0, diff.getAsJsonArray("changed_invariants").size()); + assertEquals(r.toString(), "no_violation_found", + r.getAsJsonArray("invariants").get(0).getAsJsonObject().get("verdict").getAsString()); + assertEquals(r.toString(), 3, ResidentHarness.storeStates(r)); + } + h.resident.shutdown(); + } +} diff --git a/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentRefreshExcludedInitialTest.java b/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentRefreshExcludedInitialTest.java new file mode 100644 index 0000000000..7f02617ba0 --- /dev/null +++ b/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentRefreshExcludedInitialTest.java @@ -0,0 +1,102 @@ +/******************************************************************************* + * Copyright (c) 2026 Basis Research Institute. All rights reserved. + * + * The MIT License (MIT) + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies + * of the Software, and to permit persons to whom the Software is furnished to do + * so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + ******************************************************************************/ +package tlc2.basis; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; + +/** + * An initial state a state constraint excludes is never explored, but TLC + * checks every invariant and state-level PROPERTY on it. The store keeps it, + * so a refresh that edits an invariant or property violated only there + * reports the violation, and later refreshes still carry it. + */ +public class ResidentRefreshExcludedInitialTest { + + private static String spec(final String inv, final String pos) { + return "---- MODULE I ----\n" // + + "EXTENDS Naturals\n" // + + "VARIABLE x\n" // + + "Init == x \\in {0, 10}\n" // + + "Next == x < 5 /\\ x' = x + 1\n" // + + "Bound == x < 5\n" // + + "Inv == " + inv + "\n" // + + "Pos == " + pos + "\n" // + + "====\n"; + } + + private static JsonObject first(final JsonArray verdicts) { + assertEquals(verdicts.toString(), 1, verdicts.size()); + return verdicts.get(0).getAsJsonObject(); + } + + private static JsonObject refresh(final ResidentHarness h) throws Exception { + // Past violations, so the replay covers the whole graph and the next + // refresh replays from it. + final JsonObject r = h.ok("{\"command\":\"refresh\",\"continue\":true}"); + assertEquals(r.toString(), "incremental", r.get("mode").getAsString()); + assertTrue(r.toString(), r.get("complete").getAsBoolean()); + return r; + } + + @Test + public void testExcludedInitialStates() throws Exception { + final ResidentHarness h = new ResidentHarness(); + h.write("I.cfg", "INIT Init\nNEXT Next\nCONSTRAINT Bound\nINVARIANT Inv\nPROPERTY Pos\n"); + h.write("I.tla", spec("x >= 0", "x >= 0")); + h.ok("{\"command\":\"open\",\"spec\":\"" + h.spec("I") + "\",\"workers\":1,\"deadlock\":false}"); + final JsonObject check = h.ok("{\"command\":\"check\"}"); + assertEquals(check.toString(), "ok", check.get("verdict").getAsString()); + + // Violated only by x = 10, which the constraint excludes. + h.write("I.tla", spec("x < 10", "x < 10")); + JsonObject r = refresh(h); + JsonObject inv = first(r.getAsJsonArray("invariants")); + assertEquals(r.toString(), "violated", inv.get("verdict").getAsString()); + assertEquals(r.toString(), 1, inv.get("level").getAsInt()); + final long fp = inv.get("fp").getAsLong(); + assertEquals(r.toString(), "violated_initially", + first(r.getAsJsonArray("implied_inits")).get("verdict").getAsString()); + final JsonObject trace = h.ok("{\"command\":\"trace\",\"fp\":\"" + fp + "\"}"); + assertEquals(trace.toString(), 1, trace.get("length").getAsInt()); + + // Carried through a refresh from the refreshed store. + h.write("I.tla", spec("x < 11", "x < 11")); + r = refresh(h); + assertEquals(r.toString(), "no_violation_found", + first(r.getAsJsonArray("invariants")).get("verdict").getAsString()); + h.write("I.tla", spec("x /= 10", "x /= 10")); + r = refresh(h); + inv = first(r.getAsJsonArray("invariants")); + assertEquals(r.toString(), "violated", inv.get("verdict").getAsString()); + assertEquals(r.toString(), fp, inv.get("fp").getAsLong()); + assertEquals(r.toString(), "violated_initially", + first(r.getAsJsonArray("implied_inits")).get("verdict").getAsString()); + h.resident.shutdown(); + } +} diff --git a/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentRefreshExcludedTest.java b/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentRefreshExcludedTest.java new file mode 100644 index 0000000000..99b5950b8e --- /dev/null +++ b/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentRefreshExcludedTest.java @@ -0,0 +1,87 @@ +/******************************************************************************* + * Copyright (c) 2026 Basis Research Institute. All rights reserved. + * + * The MIT License (MIT) + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies + * of the Software, and to permit persons to whom the Software is furnished to do + * so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + ******************************************************************************/ +package tlc2.basis; + +import static org.junit.Assert.assertEquals; +import static tlc2.basis.ResidentContinuationInvariantsTest.verdict; + +import org.junit.Test; + +import com.google.gson.JsonObject; + +/** + * TLC checks invariants on successors a state constraint excludes. A refresh + * must too: an invariant that fails only on the excluded state is violated + * in the refreshed graph, as a fresh run reports. + */ +public class ResidentRefreshExcludedTest { + + private static String spec(final int bad) { + return "---- MODULE RX ----\n" // + + "EXTENDS Naturals\n" // + + "VARIABLE x\n" // + + "Init == x = 0\n" // + + "Next == x' = x + 1\n" // + + "Inv == x # " + bad + "\n" // + + "Cons == x < 3\n" // + + "====\n"; + } + + @Test + public void testExcludedSuccessorIsChecked() throws Exception { + final ResidentHarness h = new ResidentHarness(); + h.write("RX.cfg", "INIT Init\nNEXT Next\nINVARIANT Inv\nCONSTRAINT Cons\n"); + h.write("RX.tla", spec(7)); + h.ok("{\"command\":\"open\",\"spec\":\"" + h.spec("RX") + "\",\"workers\":1,\"deadlock\":false}"); + final JsonObject check = h.ok("{\"command\":\"check\"}"); + assertEquals(check.toString(), "no_violation_found", verdict(check, "Inv").get("verdict").getAsString()); + // 0, 1 and 2 are in the model; 3 is excluded and kept. + assertEquals(check.toString(), 3, check.getAsJsonObject("stats").getAsJsonObject("store").get("states").getAsLong()); + assertEquals(check.toString(), 1, + check.getAsJsonObject("stats").getAsJsonObject("store").get("excluded_states").getAsLong()); + + // Only the excluded state violates the edited invariant. + h.write("RX.tla", spec(3)); + final JsonObject refresh = h.ok("{\"command\":\"refresh\"}"); + assertEquals(refresh.toString(), "incremental", refresh.get("mode").getAsString()); + final JsonObject inv = verdict(refresh, "Inv"); + assertEquals(refresh.toString(), "violated", inv.get("verdict").getAsString()); + assertEquals(refresh.toString(), 4, inv.get("level").getAsInt()); + assertEquals(refresh.toString(), 1, refresh.getAsJsonArray("violations").size()); + // The path to the excluded state is served like any other. + final JsonObject trace = h.ok("{\"command\":\"trace\",\"fp\":\"" + inv.get("fp").getAsString() + "\"}"); + assertEquals(trace.toString(), 4, trace.get("length").getAsInt()); + + // The excluded edge was carried: a further edit replays it again. + h.write("RX.tla", spec(2)); + final JsonObject again = h.ok("{\"command\":\"refresh\"}"); + assertEquals(again.toString(), "violated", verdict(again, "Inv").get("verdict").getAsString()); + assertEquals(again.toString(), 3, verdict(again, "Inv").get("level").getAsInt()); + + // And an added action that generates the excluded state anew. + h.write("RX.tla", spec(3).replace("Next == x' = x + 1", "Next == x' = x + 1 \\/ x' = x + 2")); + final JsonObject added = h.ok("{\"command\":\"refresh\",\"continue\":true}"); + assertEquals(added.toString(), "violated", verdict(added, "Inv").get("verdict").getAsString()); + h.resident.shutdown(); + } +} diff --git a/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentRefreshInvariantErrorTest.java b/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentRefreshInvariantErrorTest.java new file mode 100644 index 0000000000..1474b42941 --- /dev/null +++ b/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentRefreshInvariantErrorTest.java @@ -0,0 +1,100 @@ +/******************************************************************************* + * Copyright (c) 2026 Basis Research Institute. All rights reserved. + * + * The MIT License (MIT) + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies + * of the Software, and to permit persons to whom the Software is furnished to do + * so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + ******************************************************************************/ +package tlc2.basis; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static tlc2.basis.ResidentHarness.storeStates; + +import java.io.File; + +import org.junit.Test; + +import com.google.gson.JsonObject; + +/** + * An edited invariant that does not evaluate on a state carried over from + * the old graph: the refresh must answer as a replay that failed (not + * adopted, with the error), keep serving the old store, release the new + * one, and leave the next refresh working. + */ +public class ResidentRefreshInvariantErrorTest { + + private static String spec(final String inv) { + return "---- MODULE E ----\n" // + + "EXTENDS Naturals\n" // + + "VARIABLES x\n" // + + "Init == x = 0\n" // + + "Inc == x < 5 /\\ x' = x + 1\n" // + + "Next == Inc\n" // + + "Inv == " + inv + "\n" // + + "====\n"; + } + + private static int storeFiles(final ResidentHarness h) { + int files = 0; + final File[] metadirs = new File(h.dir.toFile(), "states").listFiles(); + for (final File metadir : metadirs == null ? new File[0] : metadirs) { + if (new File(metadir, "basis.states").exists()) { + files++; + } + } + return files; + } + + @Test + public void testInvariantThatDoesNotEvaluate() throws Exception { + final ResidentHarness h = new ResidentHarness(); + h.write("E.cfg", "INIT Init\nNEXT Next\nINVARIANT Inv\n"); + h.write("E.tla", spec("x <= 5")); + h.ok("{\"command\":\"open\",\"spec\":\"" + h.spec("E") + "\",\"workers\":1,\"deadlock\":false}"); + final JsonObject check = h.ok("{\"command\":\"check\"}"); + assertEquals("ok", check.get("verdict").getAsString()); + assertEquals(6, storeStates(check.getAsJsonObject("stats"))); + + // Every stored state survives (no action changed), and the changed + // invariant fails to evaluate on the first of them. + final int filesBefore = storeFiles(h); + h.write("E.tla", spec("x.foo <= 5")); + JsonObject r = h.ok("{\"command\":\"refresh\"}"); + assertFalse(r.toString(), r.get("adopted").getAsBoolean()); + assertTrue(r.toString(), r.get("error").getAsString().contains("Inv")); + assertEquals(6, storeStates(r)); + assertEquals("the unadopted store was not released", filesBefore, storeFiles(h)); + + // The old spec is still what the store queries evaluate against... + final long fp = h.ok("{\"command\":\"screen\",\"candidates\":[\"x < 1\"]}").getAsJsonArray("results").get(0) + .getAsJsonObject().get("first_violation_fp").getAsLong(); + final JsonObject eval = h.ok("{\"command\":\"eval\",\"fp\":" + fp + ",\"expr\":\"x + 1\"}"); + assertTrue(eval.toString(), eval.get("evaluated").getAsBoolean()); + + // ...and a fixed edit replays as usual. + h.write("E.tla", spec("x <= 4")); + r = h.ok("{\"command\":\"refresh\",\"continue\":true}"); + assertTrue(r.toString(), r.get("adopted").getAsBoolean()); + assertTrue(r.get("complete").getAsBoolean()); + assertEquals("violated", r.getAsJsonArray("invariants").get(0).getAsJsonObject().get("verdict").getAsString()); + h.resident.shutdown(); + } +} diff --git a/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentRefreshModelValueTest.java b/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentRefreshModelValueTest.java new file mode 100644 index 0000000000..caa3b426be --- /dev/null +++ b/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentRefreshModelValueTest.java @@ -0,0 +1,98 @@ +/******************************************************************************* + * Copyright (c) 2026 Basis Research Institute. All rights reserved. + * + * The MIT License (MIT) + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies + * of the Software, and to permit persons to whom the Software is furnished to do + * so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + ******************************************************************************/ +package tlc2.basis; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +import com.google.gson.JsonObject; + +/** + * Every parse resets TLC's model-value table, and a stored state names a + * model value by its index there. A refresh that is not adopted must put + * the old table back, or the store decodes against the edited spec's + * numbering; an edit that renumbers the model values cannot be replayed at + * all; one that only adds a model value after the others can. + */ +public class ResidentRefreshModelValueTest { + + private static String spec(final String values, final int bound, final boolean broken) { + return "---- MODULE MV ----\n" // + + "EXTENDS Naturals, TLCExt\n" // + + "VARIABLE x\n" // + + values // + + "Init == x = NoVal\n" // + + "A == x = NoVal /\\ x' = 0\n" // + + "B == x \\in Nat /\\ x < " + bound + " /\\ x' = x + 1\n" // + + "Next == A \\/ B" + (broken ? " \\/ ((" : "") + "\n" // + + "Inv == x = NoVal \\/ x < 10\n" // + + "====\n"; + } + + private static final String NOVAL = "NoVal == TLCModelValue(\"NoVal\")\n"; + + /** How many stored states are not NoVal, by screening the store. */ + private static long notNoVal(final ResidentHarness h) throws Exception { + final JsonObject screen = h.ok("{\"command\":\"screen\",\"candidates\":[\"x = NoVal\"]}"); + final JsonObject result = screen.getAsJsonArray("results").get(0).getAsJsonObject(); + assertEquals(result.toString(), "violated", result.get("verdict").getAsString()); + return result.get("violations").getAsLong(); + } + + @Test + public void testModelValuesSurviveRefreshes() throws Exception { + final ResidentHarness h = new ResidentHarness(); + h.write("MV.cfg", "INIT Init\nNEXT Next\nINVARIANT Inv\n"); + h.write("MV.tla", spec(NOVAL, 3, false)); + h.ok("{\"command\":\"open\",\"spec\":\"" + h.spec("MV") + "\",\"workers\":1,\"deadlock\":false}"); + assertEquals("ok", h.ok("{\"command\":\"check\"}").get("verdict").getAsString()); + assertEquals(4, notNoVal(h)); + + // A parse failure: the parse reset the table before it failed. + h.write("MV.tla", spec(NOVAL, 3, true)); + final JsonObject failed = h.call("{\"command\":\"refresh\"}"); + assertEquals(failed.toString(), "parse_failed", failed.get("error_code").getAsString()); + assertEquals(4, notNoVal(h)); + + // A model value ahead of NoVal takes its index: nothing can be carried. + h.write("MV.tla", spec("Aaa == TLCModelValue(\"Aaa\")\n" + NOVAL, 3, false)); + final JsonObject renumbered = h.ok("{\"command\":\"refresh\"}"); + assertEquals(renumbered.toString(), "full", renumbered.get("mode").getAsString()); + assertTrue(renumbered.toString(), renumbered.get("restart_required").getAsBoolean()); + assertTrue(renumbered.toString(), renumbered.get("reason").getAsString().contains("model values")); + assertEquals(4, notNoVal(h)); + + // One added after NoVal keeps every stored index: replayed as usual. + h.write("MV.tla", spec(NOVAL + "Zzz == TLCModelValue(\"Zzz\")\n", 4, false)); + final JsonObject appended = h.ok("{\"command\":\"refresh\"}"); + assertEquals(appended.toString(), "incremental", appended.get("mode").getAsString()); + assertTrue(appended.toString(), appended.get("adopted").getAsBoolean()); + assertFalse(appended.toString(), appended.has("restart_required")); + assertEquals(6, appended.getAsJsonObject("store").get("states").getAsLong()); + assertEquals(5, notNoVal(h)); + h.resident.shutdown(); + } +} diff --git a/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentRefreshPairingTest.java b/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentRefreshPairingTest.java new file mode 100644 index 0000000000..0d549fd99f --- /dev/null +++ b/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentRefreshPairingTest.java @@ -0,0 +1,78 @@ +/******************************************************************************* + * Copyright (c) 2026 Basis Research Institute. All rights reserved. + * + * The MIT License (MIT) + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies + * of the Software, and to permit persons to whom the Software is furnished to do + * so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + ******************************************************************************/ +package tlc2.basis; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; +import static tlc2.basis.ResidentHarness.storeEdges; +import static tlc2.basis.ResidentHarness.storeStates; + +import org.junit.Test; + +import com.google.gson.JsonObject; + +/** + * The disjuncts of an unnamed Next all share its name. Inserting one in front + * must leave the others paired with themselves (unchanged, their edges + * copied), not shift every pair by one. + */ +public class ResidentRefreshPairingTest { + + private static String spec(final boolean front) { + return "---- MODULE P ----\n" // + + "EXTENDS Naturals\n" // + + "VARIABLES x, y\n" // + + "Init == x = 0 /\\ y = 0\n" // + + "Next == " + (front ? "\\/ x = 99 /\\ x' = 0 /\\ y' = y\n " : "") // + + "\\/ x < 5 /\\ x' = x + 1 /\\ y' = y\n" // + + " \\/ y < 3 /\\ y' = y + 1 /\\ x' = x\n" // + + "====\n"; + } + + @Test + public void testInsertedDisjunct() throws Exception { + final ResidentHarness h = new ResidentHarness(); + h.write("P.cfg", "INIT Init\nNEXT Next\n"); + h.write("P.tla", spec(false)); + h.ok("{\"command\":\"open\",\"spec\":\"" + h.spec("P") + "\",\"workers\":1,\"deadlock\":false}"); + final JsonObject check = h.ok("{\"command\":\"check\"}"); + assertEquals(24, storeStates(check.getAsJsonObject("stats"))); + assertEquals(38, storeEdges(check.getAsJsonObject("stats"))); + + h.write("P.tla", spec(true)); + final JsonObject r = h.ok("{\"command\":\"refresh\"}"); + assertEquals(r.toString(), "incremental", r.get("mode").getAsString()); + final JsonObject diff = r.getAsJsonObject("diff"); + assertEquals(r.toString(), 2, diff.getAsJsonArray("unchanged").size()); + assertEquals(r.toString(), 0, diff.getAsJsonArray("changed").size()); + assertEquals(r.toString(), 1, diff.getAsJsonArray("added").size()); + assertEquals(r.toString(), 0, diff.getAsJsonArray("removed").size()); + // Every old edge is copied; the new disjunct is never enabled. + assertEquals(r.toString(), 38, r.get("edges_copied").getAsLong()); + assertEquals(r.toString(), 0, r.get("edges_generated").getAsLong()); + assertTrue(r.get("complete").getAsBoolean()); + assertEquals(24, storeStates(r)); + assertEquals(38, storeEdges(r)); + h.resident.shutdown(); + } +} diff --git a/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentRefreshRestoreTest.java b/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentRefreshRestoreTest.java new file mode 100644 index 0000000000..a1a66a371a --- /dev/null +++ b/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentRefreshRestoreTest.java @@ -0,0 +1,73 @@ +/******************************************************************************* + * Copyright (c) 2026 Basis Research Institute. All rights reserved. + * + * The MIT License (MIT) + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies + * of the Software, and to permit persons to whom the Software is furnished to do + * so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + ******************************************************************************/ +package tlc2.basis; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +import com.google.gson.JsonObject; + +/** + * A refresh whose edited spec declares a variable named like one of the old + * spec's definitions is not adopted; the old spec's name slots are put back, + * so the session still evaluates the definition as a definition. + */ +public class ResidentRefreshRestoreTest { + + @Test + public void testDefinitionBecomesVariable() throws Exception { + final ResidentHarness h = new ResidentHarness(); + h.write("RR.cfg", "INIT Init\nNEXT Next\n"); + h.write("RR.tla", "---- MODULE RR ----\n" // + + "EXTENDS Naturals\n" // + + "VARIABLE x\n" // + + "y == 5\n" // + + "Init == x = 0\n" // + + "Next == x < 2 /\\ x' = x + 1\n" // + + "====\n"); + h.ok("{\"command\":\"open\",\"spec\":\"" + h.spec("RR") + "\",\"workers\":1,\"deadlock\":false}"); + h.ok("{\"command\":\"check\"}"); + final String screen = "{\"command\":\"screen\",\"candidates\":[\"y = 5\",\"x + y > 4\"]}"; + assertHolds(h.ok(screen)); + + h.write("RR.tla", "---- MODULE RR ----\n" // + + "EXTENDS Naturals\n" // + + "VARIABLES x, y\n" // + + "Init == x = 0 /\\ y = 0\n" // + + "Next == x < 2 /\\ x' = x + 1 /\\ y' = y\n" // + + "====\n"); + final JsonObject refresh = h.ok("{\"command\":\"refresh\"}"); + assertTrue(refresh.toString(), refresh.get("restart_required").getAsBoolean()); + assertHolds(h.ok(screen)); + h.resident.shutdown(); + } + + private static void assertHolds(final JsonObject screen) { + for (int i = 0; i < 2; i++) { + assertEquals(screen.toString(), "holds_on_stored", + screen.getAsJsonArray("results").get(i).getAsJsonObject().get("verdict").getAsString()); + } + } +} diff --git a/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentRefreshSignatureTest.java b/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentRefreshSignatureTest.java new file mode 100644 index 0000000000..9f07e8cb77 --- /dev/null +++ b/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentRefreshSignatureTest.java @@ -0,0 +1,94 @@ +/******************************************************************************* + * Copyright (c) 2026 Basis Research Institute. All rights reserved. + * + * The MIT License (MIT) + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies + * of the Software, and to permit persons to whom the Software is furnished to do + * so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + ******************************************************************************/ +package tlc2.basis; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +import com.google.gson.JsonObject; + +/** + * An action's signature must change when what it means changes, even where + * no definition's body text does: swapping a definition's formal parameters, + * or which parameter an INSTANCE substitution replaces. + */ +public class ResidentRefreshSignatureTest { + + private static String spec(final String params, final String with) { + return "---- MODULE S ----\n" // + + "EXTENDS Integers\n" // + + "VARIABLES x, y\n" // + + "F(" + params + ") == a - b\n" // + + "I == INSTANCE SM WITH " + with + "\n" // + + "Init == x = 0 /\\ y = 0\n" // + + "A == x \\in 0..4 /\\ x' = x + F(2, 1) /\\ y' = y\n" // + + "B == y \\in 0..4 /\\ y' = y + I!D /\\ x' = x\n" // + + "Next == A \\/ B\n" // + + "Inv == x >= 0 /\\ y >= 0\n" // + + "====\n"; + } + + private static void assertChanged(final JsonObject r, final String changed, final String unchanged) { + final JsonObject diff = r.getAsJsonObject("diff"); + assertEquals(r.toString(), "incremental", r.get("mode").getAsString()); + assertEquals(r.toString(), "[\"" + changed + "\"]", diff.getAsJsonArray("changed").toString()); + assertEquals(r.toString(), "[\"" + unchanged + "\"]", diff.getAsJsonArray("unchanged").toString()); + // A fresh run of the edit steps to -1, below Inv. + assertEquals(r.toString(), "violated", + r.getAsJsonArray("invariants").get(0).getAsJsonObject().get("verdict").getAsString()); + } + + @Test + public void testSignature() throws Exception { + final ResidentHarness h = new ResidentHarness(); + h.write("S.cfg", "INIT Init\nNEXT Next\nINVARIANT Inv\n"); + h.write("SM.tla", "---- MODULE SM ----\nEXTENDS Integers\nCONSTANTS a, b\nD == a - b\n====\n"); + h.write("S.tla", spec("a, b", "a <- 2, b <- 1")); + h.ok("{\"command\":\"open\",\"spec\":\"" + h.spec("S") + "\",\"workers\":1,\"deadlock\":false}"); + final JsonObject check = h.ok("{\"command\":\"check\"}"); + assertEquals("ok", check.get("verdict").getAsString()); + assertEquals(36, ResidentHarness.storeStates(check.getAsJsonObject("stats"))); + + // The same body text, the parameters swapped. + h.write("S.tla", spec("b, a", "a <- 2, b <- 1")); + assertChanged(h.ok("{\"command\":\"refresh\"}"), "A", "B"); + + // The same expressions, substituted for the other parameters. The + // replay above stopped at its violation, so this one diffs against + // the complete original graph. + h.write("S.tla", spec("a, b", "a <- 1, b <- 2")); + final JsonObject r = h.ok("{\"command\":\"refresh\"}"); + assertChanged(r, "B", "A"); + assertEquals("last_complete", r.get("replayed_from").getAsString()); + + // Moving the text without changing it is no edit. + h.write("S.tla", spec("a, b", "a <- 2, b <- 1").replace("EXTENDS Integers\n", "EXTENDS Integers\n\n\n")); + final JsonObject moved = h.ok("{\"command\":\"refresh\"}"); + assertEquals(moved.toString(), 0, moved.getAsJsonObject("diff").getAsJsonArray("changed").size()); + assertTrue(moved.toString(), moved.get("complete").getAsBoolean()); + assertEquals(36, ResidentHarness.storeStates(moved)); + h.resident.shutdown(); + } +} diff --git a/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentRefreshStaleTest.java b/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentRefreshStaleTest.java new file mode 100644 index 0000000000..dafc648aef --- /dev/null +++ b/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentRefreshStaleTest.java @@ -0,0 +1,79 @@ +/******************************************************************************* + * Copyright (c) 2026 Basis Research Institute. All rights reserved. + * + * The MIT License (MIT) + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies + * of the Software, and to permit persons to whom the Software is furnished to do + * so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + ******************************************************************************/ +package tlc2.basis; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +import com.google.gson.JsonObject; + +/** + * After an incremental refresh, stats and registers still describe the run + * before it: they say so, and whether the refreshed store is the whole graph. + */ +public class ResidentRefreshStaleTest { + + private static String spec(final int lim) { + return "---- MODULE S ----\n" // + + "EXTENDS Naturals\n" // + + "VARIABLES x, y\n" // + + "Init == x = 0 /\\ y = 0\n" // + + "Inc == x < " + lim + " /\\ x' = x + 1 /\\ y' = y\n" // + + "Bump == y < 3 /\\ y' = y + 1 /\\ x' = x\n" // + + "Next == Inc \\/ Bump\n" // + + "====\n"; + } + + @Test + public void testStaleAfterRefresh() throws Exception { + final ResidentHarness h = new ResidentHarness(); + h.write("S.cfg", "INIT Init\nNEXT Next\n"); + h.write("S.tla", spec(5)); + h.ok("{\"command\":\"open\",\"spec\":\"" + h.spec("S") + "\",\"workers\":1,\"deadlock\":false}"); + h.ok("{\"command\":\"check\"}"); + assertFalse(h.ok("{\"command\":\"stats\"}").has("stale")); + assertFalse(h.ok("{\"command\":\"registers\"}").has("stale")); + + h.write("S.tla", spec(7)); + final JsonObject r = h.ok("{\"command\":\"refresh\"}"); + assertTrue(r.toString(), r.get("complete").getAsBoolean()); + for (final String command : new String[] { "stats", "registers" }) { + final JsonObject o = h.ok("{\"command\":\"" + command + "\"}"); + assertTrue(o.toString(), o.get("stale").getAsBoolean()); + assertTrue(o.toString(), o.get("refresh_complete").getAsBoolean()); + } + + // A refresh cut by its budget leaves a store that is not the whole graph. + h.write("S.tla", spec(9)); + final JsonObject cut = h.ok("{\"command\":\"refresh\",\"budget_ms\":-1}"); + assertFalse(cut.toString(), cut.get("complete").getAsBoolean()); + for (final String command : new String[] { "stats", "registers" }) { + final JsonObject o = h.ok("{\"command\":\"" + command + "\"}"); + assertTrue(o.toString(), o.get("stale").getAsBoolean()); + assertFalse(o.toString(), o.get("refresh_complete").getAsBoolean()); + } + h.resident.shutdown(); + } +} diff --git a/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentRefreshSymmetryTest.java b/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentRefreshSymmetryTest.java new file mode 100644 index 0000000000..ce38df9c7d --- /dev/null +++ b/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentRefreshSymmetryTest.java @@ -0,0 +1,68 @@ +/******************************************************************************* + * Copyright (c) 2026 Basis Research Institute. All rights reserved. + * + * The MIT License (MIT) + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies + * of the Software, and to permit persons to whom the Software is furnished to do + * so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + ******************************************************************************/ +package tlc2.basis; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +import com.google.gson.JsonObject; + +/** + * Under a SYMMETRY set a fingerprint names every permutation of a state, and + * the store keeps the one TLC reached first, so a refresh asks for a full + * rerun as it does under a VIEW. + */ +public class ResidentRefreshSymmetryTest { + + private static String spec(final boolean remove) { + return "---- MODULE S ----\n" // + + "EXTENDS TLC\n" // + + "CONSTANT P\n" // + + "VARIABLE s\n" // + + "Init == s = {}\n" // + + "Add == \\E p \\in P : p \\notin s /\\ s' = s \\cup {p}\n" // + + (remove ? "Remove == \\E p \\in s : s' = s \\ {p}\n" : "") // + + "Next == Add" + (remove ? " \\/ Remove" : "") + "\n" // + + "Inv == s \\subseteq P\n" // + + "Perms == Permutations(P)\n" // + + "====\n"; + } + + @Test + public void testSymmetryRefreshIsFullRerun() throws Exception { + final ResidentHarness h = new ResidentHarness(); + h.write("S.cfg", "CONSTANT P = {p1, p2}\nSYMMETRY Perms\nINIT Init\nNEXT Next\nINVARIANT Inv\n"); + h.write("S.tla", spec(false)); + h.ok("{\"command\":\"open\",\"spec\":\"" + h.spec("S") + "\",\"workers\":1,\"deadlock\":false}"); + assertEquals("ok", h.ok("{\"command\":\"check\"}").get("verdict").getAsString()); + + h.write("S.tla", spec(true)); + final JsonObject r = h.ok("{\"command\":\"refresh\"}"); + assertEquals(r.toString(), "full", r.get("mode").getAsString()); + assertTrue(r.toString(), r.get("restart_required").getAsBoolean()); + assertTrue(r.toString(), r.get("reason").getAsString().contains("SYMMETRY")); + h.resident.shutdown(); + } +} diff --git a/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentRefreshTLCGetSubstitutionTest.java b/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentRefreshTLCGetSubstitutionTest.java new file mode 100644 index 0000000000..c2daffd928 --- /dev/null +++ b/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentRefreshTLCGetSubstitutionTest.java @@ -0,0 +1,71 @@ +/******************************************************************************* + * Copyright (c) 2026 Basis Research Institute. All rights reserved. + * + * The MIT License (MIT) + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies + * of the Software, and to permit persons to whom the Software is furnished to do + * so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + ******************************************************************************/ +package tlc2.basis; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +import com.google.gson.JsonObject; + +/** + * A constraint that reads {@code TLCGet("level")} only through a definition + * the config substitutes in ({@code CONSTANT Depth <- D}): the substitution + * is bound as a tool object, so the syntactic walk from the constraint never + * reaches {@code TLCGet}. The refresh must still ask for a full run; replayed + * without a predecessor, the constraint excluded nothing and the replay ran + * away. + */ +public class ResidentRefreshTLCGetSubstitutionTest { + + private static String spec(final String next) { + return "---- MODULE S ----\n" // + + "EXTENDS Naturals, TLC\n" // + + "VARIABLES x\n" // + + "Depth == 0\n" // + + "D == TLCGet(\"level\")\n" // + + "Constr == Depth < 4\n" // + + "Init == x = 0\n" // + + "Inc == x' = x + 1\n" // + + "Jump == x' = x + 3\n" // + + "Next == " + next + "\n" // + + "Inv == TRUE\n" // + + "====\n"; + } + + @Test + public void testSubstitutedLevelConstraint() throws Exception { + final ResidentHarness h = new ResidentHarness(); + h.write("S.cfg", "INIT Init\nNEXT Next\nINVARIANT Inv\nCONSTRAINT Constr\nCONSTANT Depth <- D\n"); + h.write("S.tla", spec("Inc")); + h.ok("{\"command\":\"open\",\"spec\":\"" + h.spec("S") + "\",\"workers\":1,\"deadlock\":false}"); + assertEquals("ok", h.ok("{\"command\":\"check\"}").get("verdict").getAsString()); + h.write("S.tla", spec("Inc \\/ Jump")); + final JsonObject r = h.ok("{\"command\":\"refresh\",\"budget_ms\":10000}"); + assertEquals(r.toString(), "full", r.get("mode").getAsString()); + assertTrue(r.toString(), r.get("restart_required").getAsBoolean()); + assertTrue(r.toString(), r.get("reason").getAsString().contains("Depth <- D")); + h.resident.shutdown(); + } +} diff --git a/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentRefreshTLCGetTest.java b/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentRefreshTLCGetTest.java new file mode 100644 index 0000000000..a4e0fa62b3 --- /dev/null +++ b/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentRefreshTLCGetTest.java @@ -0,0 +1,67 @@ +/******************************************************************************* + * Copyright (c) 2026 Basis Research Institute. All rights reserved. + * + * The MIT License (MIT) + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies + * of the Software, and to permit persons to whom the Software is furnished to do + * so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + ******************************************************************************/ +package tlc2.basis; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +import com.google.gson.JsonObject; + +/** + * A spec whose constraint reads {@code TLCGet("level")}: whether a successor + * is in the model depends on the path to it, which an edit can shorten, so a + * refresh asks for a full run instead of copying edges (and instead of + * failing to evaluate the constraint without a predecessor). + */ +public class ResidentRefreshTLCGetTest { + + private static String spec(final int step) { + return "---- MODULE L ----\n" // + + "EXTENDS Naturals, TLC\n" // + + "VARIABLES x, y\n" // + + "Depth == TLCGet(\"level\") < 4\n" // + + "Init == x = 0 /\\ y = 0\n" // + + "Inc == x' = x + 1 /\\ y' = y\n" // + + "Bump == y' = y + " + step + " /\\ x' = x\n" // + + "Next == Inc \\/ Bump\n" // + + "Inv == x + y < 100\n" // + + "====\n"; + } + + @Test + public void testLevelConstraint() throws Exception { + final ResidentHarness h = new ResidentHarness(); + h.write("L.cfg", "INIT Init\nNEXT Next\nINVARIANT Inv\nCONSTRAINT Depth\n"); + h.write("L.tla", spec(1)); + h.ok("{\"command\":\"open\",\"spec\":\"" + h.spec("L") + "\",\"workers\":1,\"deadlock\":false}"); + assertEquals("ok", h.ok("{\"command\":\"check\"}").get("verdict").getAsString()); + h.write("L.tla", spec(2)); + final JsonObject r = h.ok("{\"command\":\"refresh\"}"); + assertEquals(r.toString(), "full", r.get("mode").getAsString()); + assertTrue(r.toString(), r.get("restart_required").getAsBoolean()); + assertTrue(r.toString(), r.get("reason").getAsString().contains("TLCGet")); + h.resident.shutdown(); + } +} diff --git a/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentRefreshTest.java b/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentRefreshTest.java new file mode 100644 index 0000000000..f72c439859 --- /dev/null +++ b/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentRefreshTest.java @@ -0,0 +1,139 @@ +/******************************************************************************* + * Copyright (c) 2026 Basis Research Institute. All rights reserved. + * + * The MIT License (MIT) + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies + * of the Software, and to permit persons to whom the Software is furnished to do + * so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + ******************************************************************************/ +package tlc2.basis; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static tlc2.basis.ResidentHarness.storeEdges; +import static tlc2.basis.ResidentHarness.storeStates; + +import java.io.File; + +import org.junit.Test; + +import com.google.gson.JsonObject; + +/** + * Incremental refresh against the graph a fresh run explores: a sequence of + * edits (a changed guard, a changed definition, an added and a removed + * action), each replay matching the fresh run's states and edges; then an + * edit that breaks an invariant followed by its fix, which must replay from + * the last complete graph rather than ask for a restart; then an edit that + * does not evaluate, which must leave the store in place. + */ +public class ResidentRefreshTest { + + private static String spec(final int lim, final int blim, final String inv, final boolean extra, + final boolean broken) { + return "---- MODULE R ----\n" // + + "EXTENDS Naturals\n" // + + "VARIABLES x, y\n" // + + "Lim == " + lim + "\n" // + + "Init == x = 0 /\\ y = 0\n" // + + "Inc == x < Lim /\\ x' = x + 1 /\\ y' = y\n" // + + "Bump == y < " + blim + " /\\ y' = y + 1 /\\ x' = x\n" // + + "Reset == x = Lim /\\ x' = 0 /\\ y' = y\n" // + + (extra ? "Extra == x = 2 /\\ x' = 6 /\\ y' = y\n" : "") // + + "Next == " + (broken ? "x = " : "") + "Inc \\/ Bump \\/ Reset" + (extra ? " \\/ Extra" : "") + "\n" // + + "Inv == " + inv + "\n" // + + "====\n"; + } + + @Test + public void testRefresh() throws Exception { + final ResidentHarness h = new ResidentHarness(); + h.write("R.cfg", "INIT Init\nNEXT Next\nINVARIANT Inv\n"); + h.write("R.tla", spec(5, 3, "x + y < 100", false, false)); + h.ok("{\"command\":\"open\",\"spec\":\"" + h.spec("R") + "\",\"workers\":1,\"deadlock\":false}"); + final JsonObject check = h.ok("{\"command\":\"check\"}"); + assertEquals("ok", check.get("verdict").getAsString()); + assertEquals(24, storeStates(check.getAsJsonObject("stats"))); + assertEquals(42, storeEdges(check.getAsJsonObject("stats"))); + + // Each edit's expected (states, edges) is what a fresh run stores. + final Object[][] edits = { // + { spec(5, 4, "x + y < 100", false, false), 30, 54, "Bump" }, // a changed guard + { spec(7, 4, "x + y < 100", false, false), 40, 72, "Inc" }, // a changed definition + { spec(7, 4, "x + y < 100", true, false), 40, 77, null }, // an added action + { spec(7, 2, "x + y < 100", false, false), 24, 40, "Bump" }, // removed, and changed + }; + for (final Object[] e : edits) { + h.write("R.tla", (String) e[0]); + final JsonObject r = h.ok("{\"command\":\"refresh\"}"); + assertEquals(r.toString(), "incremental", r.get("mode").getAsString()); + assertTrue(r.get("complete").getAsBoolean()); + assertEquals(r.toString(), ((Integer) e[1]).longValue(), storeStates(r)); + assertEquals(r.toString(), ((Integer) e[2]).longValue(), storeEdges(r)); + if (e[3] != null) { + assertTrue(r.toString(), r.getAsJsonObject("diff").getAsJsonArray("changed").toString() + .contains((String) e[3])); + } + assertTrue(r.has("unchecked")); + } + + // An edit that breaks the invariant stops at its first violation... + h.write("R.tla", spec(7, 2, "x + y < 6", false, false)); + JsonObject r = h.ok("{\"command\":\"refresh\"}"); + assertEquals("incremental", r.get("mode").getAsString()); + assertFalse(r.get("complete").getAsBoolean()); + assertTrue(r.get("stopped_at_first_violation").getAsBoolean()); + assertEquals("violated", r.getAsJsonArray("invariants").get(0).getAsJsonObject().get("verdict").getAsString()); + + // ...and the fix replays from the last complete graph, not a restart. + h.write("R.tla", spec(7, 2, "x + y < 100", false, false)); + r = h.ok("{\"command\":\"refresh\"}"); + assertEquals(r.toString(), "incremental", r.get("mode").getAsString()); + assertEquals("last_complete", r.get("replayed_from").getAsString()); + assertTrue(r.get("complete").getAsBoolean()); + assertEquals(24, storeStates(r)); + assertEquals(40, storeEdges(r)); + + // An edit that does not evaluate is not adopted: the store stays. + final JsonObject screen = h.ok("{\"command\":\"screen\",\"candidates\":[\"x < 1\"]}"); + final long fp = screen.getAsJsonArray("results").get(0).getAsJsonObject().get("first_violation_fp") + .getAsLong(); + h.write("R.tla", spec(7, 2, "x + y < 100", false, true)); + r = h.ok("{\"command\":\"refresh\"}"); + assertFalse(r.toString(), r.get("adopted").getAsBoolean()); + assertTrue(r.has("error")); + assertEquals(24, storeStates(r)); + final JsonObject eval = h.ok("{\"command\":\"eval\",\"fp\":" + fp + ",\"expr\":\"x + y\"}"); + assertTrue(eval.toString(), eval.get("evaluated").getAsBoolean()); + + // Guards are not tallied during a replay; the profile says so. + assertTrue(h.ok("{\"command\":\"guard_profile\"}").get("stale").getAsBoolean()); + + // Stores nothing refers to are released: only the current store's + // file is left (a metadir that held nothing else goes with it). + final String current = h.ok("{\"command\":\"store\"}").toString(); + int files = 0; + for (final File metadir : new File(h.dir.toFile(), "states").listFiles()) { + if (new File(metadir, "basis.states").exists()) { + files++; + } + } + assertEquals(current, 1, files); + h.resident.shutdown(); + } +} diff --git a/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentRefreshTraceTest.java b/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentRefreshTraceTest.java new file mode 100644 index 0000000000..971a526bcd --- /dev/null +++ b/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentRefreshTraceTest.java @@ -0,0 +1,64 @@ +/******************************************************************************* + * Copyright (c) 2026 Basis Research Institute. All rights reserved. + * + * The MIT License (MIT) + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies + * of the Software, and to permit persons to whom the Software is furnished to do + * so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + ******************************************************************************/ +package tlc2.basis; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +import com.google.gson.JsonObject; + +/** + * An invariant that reads the behaviour so far ({@code TLCExt!Trace}, which + * TLC overrides in Java to walk the predecessor chain): its value depends on + * the path to a state, which a replay neither preserves nor sets, so a + * refresh asks for a full run. + */ +public class ResidentRefreshTraceTest { + + private static String spec(final int step) { + return "---- MODULE T ----\n" // + + "EXTENDS Naturals, Sequences, TLCExt\n" // + + "VARIABLES x\n" // + + "Init == x = 0\n" // + + "Inc == x < 5 /\\ x' = x + " + step + "\n" // + + "Next == Inc\n" // + + "Inv == Len(Trace) < 100\n" // + + "====\n"; + } + + @Test + public void testTraceInvariant() throws Exception { + final ResidentHarness h = new ResidentHarness(); + h.write("T.cfg", "INIT Init\nNEXT Next\nINVARIANT Inv\n"); + h.write("T.tla", spec(1)); + h.ok("{\"command\":\"open\",\"spec\":\"" + h.spec("T") + "\",\"workers\":1,\"deadlock\":false}"); + assertEquals("ok", h.ok("{\"command\":\"check\"}").get("verdict").getAsString()); + h.write("T.tla", spec(2)); + final JsonObject r = h.ok("{\"command\":\"refresh\",\"budget_ms\":10000}"); + assertEquals(r.toString(), "full", r.get("mode").getAsString()); + assertTrue(r.toString(), r.get("restart_required").getAsBoolean()); + h.resident.shutdown(); + } +} diff --git a/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentRefreshViewTest.java b/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentRefreshViewTest.java new file mode 100644 index 0000000000..8cf6ed6c06 --- /dev/null +++ b/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentRefreshViewTest.java @@ -0,0 +1,70 @@ +/******************************************************************************* + * Copyright (c) 2026 Basis Research Institute. All rights reserved. + * + * The MIT License (MIT) + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies + * of the Software, and to permit persons to whom the Software is furnished to do + * so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + ******************************************************************************/ +package tlc2.basis; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +import com.google.gson.JsonObject; + +/** + * Under a VIEW a refresh asks for a full rerun. Here x = 1 is first reached + * with y = 1 by A, whose store content is (1, 1); B reaches it with y = 2. + * Once A changes, (1, 1) is unreachable, yet a replay copying B's edge would + * store it and carry D from it to x = 5, a state a fresh run never reaches. + */ +public class ResidentRefreshViewTest { + + private static String spec(final String ay) { + return "---- MODULE V ----\n" // + + "VARIABLES x, y\n" // + + "Init == x = 0 /\\ y = 0\n" // + + "A == x = 0 /\\ x' = 1 /\\ y' = " + ay + "\n" // + + "B == x = 0 /\\ x' = 1 /\\ y' = 2\n" // + + "D == x = 1 /\\ y = 1 /\\ x' = 5 /\\ y' = 0\n" // + + "Next == A \\/ B \\/ D\n" // + + "Inv == x /= 7\n" // + + "View == x\n" // + + "====\n"; + } + + @Test + public void testViewRefreshIsFullRerun() throws Exception { + final ResidentHarness h = new ResidentHarness(); + h.write("V.cfg", "INIT Init\nNEXT Next\nINVARIANT Inv\nVIEW View\n"); + h.write("V.tla", spec("1")); + h.ok("{\"command\":\"open\",\"spec\":\"" + h.spec("V") + "\",\"workers\":1,\"deadlock\":false}"); + assertEquals("ok", h.ok("{\"command\":\"check\"}").get("verdict").getAsString()); + + h.write("V.tla", spec("3")); + final JsonObject r = h.ok("{\"command\":\"refresh\"}"); + assertEquals(r.toString(), "full", r.get("mode").getAsString()); + assertTrue(r.toString(), r.get("restart_required").getAsBoolean()); + assertTrue(r.toString(), r.get("reason").getAsString().contains("VIEW")); + // Nothing was adopted: the old store still serves. + h.ok("{\"command\":\"screen\",\"candidates\":[\"x /= 5\"]}"); + h.resident.shutdown(); + } +} diff --git a/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentRefreshWorkersTest.java b/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentRefreshWorkersTest.java new file mode 100644 index 0000000000..a628c24b7d --- /dev/null +++ b/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentRefreshWorkersTest.java @@ -0,0 +1,76 @@ +/******************************************************************************* + * Copyright (c) 2026 Basis Research Institute. All rights reserved. + * + * The MIT License (MIT) + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies + * of the Software, and to permit persons to whom the Software is furnished to do + * so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + ******************************************************************************/ +package tlc2.basis; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; +import static tlc2.basis.ResidentHarness.storeEdges; +import static tlc2.basis.ResidentHarness.storeStates; + +import org.junit.Test; + +import com.google.gson.JsonObject; + +/** + * The store and its incremental refresh with several workers writing it + * concurrently: each replay must hold what a fresh run stores. With b the + * bound on y, a fresh run reaches 21(b+1) states over 42b+21 edges. + */ +public class ResidentRefreshWorkersTest { + + private static String spec(final int b) { + return "---- MODULE W ----\n" // + + "EXTENDS Naturals\n" // + + "VARIABLES x, y\n" // + + "Lim == 20\n" // + + "Init == x = 0 /\\ y = 0\n" // + + "Inc == x < Lim /\\ x' = x + 1 /\\ y' = y\n" // + + "Bump == y < " + b + " /\\ y' = y + 1 /\\ x' = x\n" // + + "Reset == x = Lim /\\ x' = 0 /\\ y' = y\n" // + + "Next == Inc \\/ Bump \\/ Reset\n" // + + "Inv == x + y < 1000\n" // + + "====\n"; + } + + @Test(timeout = 120_000) + public void testRefreshWithWorkers() throws Exception { + final ResidentHarness h = new ResidentHarness(); + h.write("W.cfg", "INIT Init\nNEXT Next\nINVARIANT Inv\n"); + h.write("W.tla", spec(10)); + h.ok("{\"command\":\"open\",\"spec\":\"" + h.spec("W") + "\",\"workers\":4,\"deadlock\":false}"); + final JsonObject check = h.ok("{\"command\":\"check\"}"); + assertEquals("ok", check.get("verdict").getAsString()); + assertEquals(231, storeStates(check.getAsJsonObject("stats"))); + assertEquals(441, storeEdges(check.getAsJsonObject("stats"))); + + for (final int b : new int[] { 15, 7, 12 }) { + h.write("W.tla", spec(b)); + final JsonObject r = h.ok("{\"command\":\"refresh\"}"); + assertEquals(r.toString(), "incremental", r.get("mode").getAsString()); + assertTrue(r.toString(), r.get("complete").getAsBoolean()); + assertEquals(r.toString(), 21 * (b + 1), storeStates(r)); + assertEquals(r.toString(), 42 * b + 21, storeEdges(r)); + } + h.resident.shutdown(); + } +} diff --git a/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentShutdownCleanupTest.java b/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentShutdownCleanupTest.java new file mode 100644 index 0000000000..f2b52d1870 --- /dev/null +++ b/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentShutdownCleanupTest.java @@ -0,0 +1,97 @@ +/******************************************************************************* + * Copyright (c) 2026 Basis Research Institute. All rights reserved. + * + * The MIT License (MIT) + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies + * of the Software, and to permit persons to whom the Software is furnished to do + * so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + ******************************************************************************/ +package tlc2.basis; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import java.nio.file.Files; +import java.util.stream.Stream; + +import org.junit.Test; + +import com.google.gson.JsonObject; + +/** + * A refresh keeps its store in a metadir of its own under the spec's + * directory (TLC removes the run's metadir itself when the run ends): a + * session that ends removes it. + */ +public class ResidentShutdownCleanupTest { + + private static String spec(final int lim) { + return "---- MODULE C ----\n" // + + "EXTENDS Naturals\n" // + + "VARIABLE x\n" // + + "Init == x = 0\n" // + + "Inc == x < " + lim + " /\\ x' = x + 1\n" // + + "Next == Inc\n" // + + "====\n"; + } + + private static long storeFiles(final ResidentHarness h) throws Exception { + try (Stream files = Files.walk(h.dir)) { + return files.filter(p -> p.getFileName().toString().equals("basis.states")).count(); + } + } + + /** Everything left under the spec's directory, for a failure message. */ + private static String left(final ResidentHarness h) throws Exception { + try (Stream files = Files.walk(h.dir)) { + return files.map(p -> h.dir.relativize(p).toString()).collect(java.util.stream.Collectors.joining(", ")); + } + } + + /** The metadirs left under the spec's directory. */ + private static long metadirs(final ResidentHarness h) throws Exception { + final java.nio.file.Path states = h.dir.resolve("states"); + if (!Files.isDirectory(states)) { + return 0; + } + try (Stream dirs = Files.list(states)) { + return dirs.count(); + } + } + + @Test + public void testShutdownRemovesStoreFiles() throws Exception { + final ResidentHarness h = new ResidentHarness(); + h.write("C.cfg", "INIT Init\nNEXT Next\n"); + h.write("C.tla", spec(3)); + h.ok("{\"command\":\"open\",\"spec\":\"" + h.spec("C") + "\",\"workers\":1,\"deadlock\":false}"); + assertEquals("ok", h.ok("{\"command\":\"check\"}").get("verdict").getAsString()); + // A refresh stopped by its budget (here, at once) keeps the complete + // run's store as the base and serves its own, in its own metadir. + h.write("C.tla", spec(6)); + final JsonObject partial = h.ok("{\"command\":\"refresh\",\"budget_ms\":-1}"); + assertTrue(partial.toString(), partial.get("adopted").getAsBoolean()); + assertFalse(partial.toString(), partial.get("complete").getAsBoolean()); + assertTrue(storeFiles(h) >= 1); + assertTrue(metadirs(h) >= 1); + + h.resident.shutdown(); + assertEquals(left(h), 0, storeFiles(h)); + assertEquals(left(h), 0, metadirs(h)); + } +} diff --git a/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentSimulateBudgetTest.java b/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentSimulateBudgetTest.java new file mode 100644 index 0000000000..bfd71f05f4 --- /dev/null +++ b/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentSimulateBudgetTest.java @@ -0,0 +1,62 @@ +/******************************************************************************* + * Copyright (c) 2026 Basis Research Institute. All rights reserved. + * + * The MIT License (MIT) + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies + * of the Software, and to permit persons to whom the Software is furnished to do + * so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + ******************************************************************************/ +package tlc2.basis; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +import com.google.gson.JsonObject; + +/** + * A budget stop ends the simulator for good: the reply says it stopped on + * its budget with nothing found, and a later call reports that run again as + * not resumable instead of as a completed one. + */ +public class ResidentSimulateBudgetTest { + + @Test + public void testBudgetStopIsFinal() throws Exception { + final ResidentHarness h = new ResidentHarness(); + h.write("SB.cfg", "INIT Init\nNEXT Next\nINVARIANT Ok\n"); + h.write("SB.tla", "---- MODULE SB ----\n" // + + "EXTENDS Naturals\n" // + + "VARIABLE x\n" // + + "Init == x = 0\n" // + + "Next == x' = 1 - x\n" // + + "Ok == x \\in {0, 1}\n" // + + "====\n"); + h.ok("{\"command\":\"open\",\"spec\":\"" + h.spec("SB") + + "\",\"mode\":\"simulate\",\"workers\":1,\"depth\":10,\"seed\":1}"); + final JsonObject first = h.ok("{\"command\":\"simulate\",\"budget_ms\":300}"); + assertTrue(first.toString(), first.get("stopped_by_budget").getAsBoolean()); + assertEquals(first.toString(), "no_violation_found", first.get("verdict").getAsString()); + final JsonObject again = h.ok("{\"command\":\"simulate\",\"budget_ms\":300}"); + assertFalse(again.toString(), again.get("resumable").getAsBoolean()); + assertTrue(again.toString(), again.get("stopped_by_budget").getAsBoolean()); + assertEquals(again.toString(), "no_violation_found", again.get("verdict").getAsString()); + h.resident.shutdown(); + } +} diff --git a/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentSimulateErrorTest.java b/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentSimulateErrorTest.java new file mode 100644 index 0000000000..a48a67d5d2 --- /dev/null +++ b/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentSimulateErrorTest.java @@ -0,0 +1,65 @@ +/******************************************************************************* + * Copyright (c) 2026 Basis Research Institute. All rights reserved. + * + * The MIT License (MIT) + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies + * of the Software, and to permit persons to whom the Software is furnished to do + * so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + ******************************************************************************/ +package tlc2.basis; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +import com.google.gson.JsonObject; + +import tlc2.output.EC; + +/** + * A simulation that stops because the next-state relation fails to evaluate + * ends on an error no violation code reports: its verdict is an error, with + * the behaviour that led there, never a clean run. + */ +public class ResidentSimulateErrorTest { + + @Test + public void testEvaluationErrorIsAnError() throws Exception { + final ResidentHarness h = new ResidentHarness(); + h.write("SE.cfg", "INIT Init\nNEXT Next\nINVARIANT Small\n"); + h.write("SE.tla", "---- MODULE SE ----\n" // + + "EXTENDS Naturals\n" // + + "VARIABLE x\n" // + + "Init == x = 0\n" // + + "Next == x' = IF x = 3 THEN CHOOSE y \\in {} : TRUE ELSE x + 1\n" // + + "Small == x < 10\n" // + + "====\n"); + h.ok("{\"command\":\"open\",\"spec\":\"" + h.spec("SE") + + "\",\"mode\":\"simulate\",\"workers\":1,\"depth\":10,\"traces\":5,\"seed\":1}"); + final JsonObject sim = h.ok("{\"command\":\"simulate\",\"budget_ms\":30000}"); + assertTrue(sim.toString(), sim.get("finished").getAsBoolean()); + assertEquals(sim.toString(), "error", sim.get("verdict").getAsString()); + assertNotEquals(sim.toString(), EC.NO_ERROR, sim.get("result_code").getAsInt()); + assertEquals(sim.toString(), 4, sim.getAsJsonObject("trace").get("length").getAsInt()); + // A simulate session keeps no store, and its store queries say so. + final JsonObject trace = h.call("{\"command\":\"trace\",\"fp\":1}"); + assertEquals(trace.toString(), "no_store", trace.get("error_code").getAsString()); + h.resident.shutdown(); + } +} diff --git a/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentSimulateViolationTest.java b/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentSimulateViolationTest.java new file mode 100644 index 0000000000..36759f2186 --- /dev/null +++ b/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentSimulateViolationTest.java @@ -0,0 +1,55 @@ +/******************************************************************************* + * Copyright (c) 2026 Basis Research Institute. All rights reserved. + * + * The MIT License (MIT) + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies + * of the Software, and to permit persons to whom the Software is furnished to do + * so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + ******************************************************************************/ +package tlc2.basis; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +import com.google.gson.JsonObject; + +/** A simulation that reaches an invariant violation reports it with its behaviour. */ +public class ResidentSimulateViolationTest { + + @Test + public void testViolationIsReported() throws Exception { + final ResidentHarness h = new ResidentHarness(); + h.write("SV.cfg", "INIT Init\nNEXT Next\nINVARIANT Small\n"); + h.write("SV.tla", "---- MODULE SV ----\n" // + + "EXTENDS Naturals\n" // + + "VARIABLE x\n" // + + "Init == x = 0\n" // + + "Next == x' = x + 1\n" // + + "Small == x < 3\n" // + + "====\n"); + h.ok("{\"command\":\"open\",\"spec\":\"" + h.spec("SV") + + "\",\"mode\":\"simulate\",\"workers\":1,\"depth\":10,\"traces\":5,\"seed\":1}"); + final JsonObject sim = h.ok("{\"command\":\"simulate\",\"budget_ms\":30000}"); + assertTrue(sim.toString(), sim.get("finished").getAsBoolean()); + assertEquals(sim.toString(), "invariant_violated", sim.get("verdict").getAsString()); + assertEquals(sim.toString(), "Small", sim.get("violated").getAsString()); + assertEquals(sim.toString(), 4, sim.getAsJsonObject("trace").get("length").getAsInt()); + h.resident.shutdown(); + } +} diff --git a/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentViewStoreTest.java b/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentViewStoreTest.java new file mode 100644 index 0000000000..e149f43dae --- /dev/null +++ b/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentViewStoreTest.java @@ -0,0 +1,135 @@ +/******************************************************************************* + * Copyright (c) 2026 Basis Research Institute. All rights reserved. + * + * The MIT License (MIT) + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies + * of the Software, and to permit persons to whom the Software is furnished to do + * so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + ******************************************************************************/ +package tlc2.basis; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; +import static tlc2.basis.ResidentHarness.storeStates; + +import java.util.ArrayDeque; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; + +import org.junit.Test; + +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; + +/** + * Under a VIEW, several concrete states share a fingerprint and TLC explores + * only the one whose fingerprint-set put won. The store must keep that one: + * its recorded out-edges are the successors TLC generated from it, and the + * store queries and the invariant sweeps work from its + * content. Here the hidden variable decides the successors, so content from + * the wrong concrete state would disagree with the recorded edges. Run with + * several workers, whose writes race for the store's lock. A refresh under + * a VIEW asks for a full rerun and leaves the store as it was. + */ +public class ResidentViewStoreTest { + + private static String spec(final boolean extra) { + return "---- MODULE V ----\n" // + + "EXTENDS Naturals\n" // + + "VARIABLES x, y\n" // + + "Init == x = 0 /\\ y = 0\n" // + + "A == x < 6 /\\ x' = x + 1 + y /\\ y' = 0\n" // + + "B == x < 6 /\\ x' = x + 1 /\\ y' = 1\n" // + + (extra ? "C == x = 1 /\\ x' = 4 /\\ y' = 1 - y\n" : "") // + + "Next == A \\/ B" + (extra ? " \\/ C" : "") + "\n" // + + "View == x\n" // + + "Inv == x <= 7\n" // + + "====\n"; + } + + /** + * Walk the store from its initial state through the successors each + * stored state's content generates, and check them against the edges the + * store recorded out of that state. Returns the number of states walked. + */ + private static int consistent(final ResidentHarness h) throws Exception { + final JsonObject screen = h.ok("{\"command\":\"screen\",\"candidates\":[\"x # 0\"]}"); + final long init = screen.getAsJsonArray("results").get(0).getAsJsonObject().get("first_violation_fp") + .getAsLong(); + final Map> generated = new HashMap<>(); + final Map> recorded = new HashMap<>(); + final ArrayDeque queue = new ArrayDeque<>(); + final Set seen = new HashSet<>(); + queue.add(init); + seen.add(init); + while (!queue.isEmpty()) { + final long fp = queue.poll(); + final JsonObject n = h.ok("{\"command\":\"neighbours\",\"fp\":\"" + fp + "\"}"); + final Set out = generated.computeIfAbsent(fp, k -> new HashSet<>()); + final JsonArray succs = n.getAsJsonArray("successors"); + for (int i = 0; i < succs.size(); i++) { + final JsonObject a = succs.get(i).getAsJsonObject(); + final JsonArray states = a.getAsJsonArray("states"); + for (int j = 0; j < states.size(); j++) { + final JsonObject s = states.get(j).getAsJsonObject(); + assertTrue(n.toString(), s.get("stored").getAsBoolean()); + final long to = s.get("fp").getAsLong(); + out.add(to + "/" + a.get("action_id").getAsLong()); + if (seen.add(to)) { + queue.add(to); + } + } + } + final JsonArray preds = n.getAsJsonArray("predecessors"); + for (int i = 0; i < preds.size(); i++) { + final JsonObject p = preds.get(i).getAsJsonObject(); + recorded.computeIfAbsent(p.get("fp").getAsLong(), k -> new HashSet<>()) + .add(fp + "/" + p.get("action_id").getAsLong()); + } + } + for (final long fp : seen) { + assertEquals("state " + fp, generated.get(fp), recorded.getOrDefault(fp, new HashSet<>())); + } + return seen.size(); + } + + @Test + public void testViewStore() throws Exception { + final ResidentHarness h = new ResidentHarness(); + h.write("V.cfg", "INIT Init\nNEXT Next\nVIEW View\nINVARIANT Inv\n"); + h.write("V.tla", spec(false)); + h.ok("{\"command\":\"open\",\"spec\":\"" + h.spec("V") + "\",\"workers\":4,\"deadlock\":false}"); + final JsonObject check = h.ok("{\"command\":\"check\"}"); + assertEquals(check.toString(), "ok", check.get("verdict").getAsString()); + final JsonObject stats = check.getAsJsonObject("stats"); + assertEquals(stats.get("distinct").getAsLong(), storeStates(stats)); + assertEquals(storeStates(stats), consistent(h)); + + // A refresh under a VIEW is refused: a copied edge may reach a stored + // state other than the one it generates (ResidentRefreshViewTest). + // The store stays and still serves. + h.write("V.tla", spec(true)); + final JsonObject r = h.ok("{\"command\":\"refresh\"}"); + assertEquals(r.toString(), "full", r.get("mode").getAsString()); + assertTrue(r.toString(), r.get("restart_required").getAsBoolean()); + assertTrue(r.toString(), r.get("reason").getAsString().contains("VIEW")); + assertEquals(storeStates(stats), consistent(h)); + h.resident.shutdown(); + } +} diff --git a/tlatools/org.lamport.tlatools/test/tlc2/tool/DotConstrainedGuardTest.java b/tlatools/org.lamport.tlatools/test/tlc2/tool/DotConstrainedGuardTest.java new file mode 100644 index 0000000000..916252d6c6 --- /dev/null +++ b/tlatools/org.lamport.tlatools/test/tlc2/tool/DotConstrainedGuardTest.java @@ -0,0 +1,107 @@ +/******************************************************************************* + * Copyright (c) 2026 Basis Research Institute. All rights reserved. + * + * The MIT License (MIT) + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies + * of the Software, and to permit persons to whom the Software is furnished to do + * so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + ******************************************************************************/ +package tlc2.tool; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +import java.io.IOException; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; + +import org.junit.Test; + +import tla2sany.semantic.SemanticNode; +import tlc2.output.EC; +import tlc2.output.EC.ExitStatus; +import tlc2.tool.liveness.ModelCheckerTestCase; +import tlc2.util.DotStateWriter; +import tlc2.util.IStateWriter; +import util.FileUtil; + +/** + * Guards that evaluate false before any primed variable is assigned reach a + * constrained writer through {@link IStateWriter#writeUnsatisfied}; the + * default must not hand {@link DotStateWriter} a partially assigned + * successor (it fingerprints it). + */ +public class DotConstrainedGuardTest extends ModelCheckerTestCase { + + public DotConstrainedGuardTest() { + super("Guards", "basis", + new String[] { "-deadlock", "-dump", "dot,constrained", + "${metadir}" + FileUtil.separator + DotConstrainedGuardTest.class.getCanonicalName() + ".dot" }, + ExitStatus.SUCCESS); + } + + @Override + protected boolean doDump() { + return false; + } + + @Override + protected boolean doCoverage() { + return false; + } + + private final AtomicBoolean partial = new AtomicBoolean(false); + private final AtomicInteger unsatisfied = new AtomicInteger(); + + @Override + protected IStateWriter getStateWriter(final IStateWriter sw) { + try { + return new DotStateWriter(sw.getDumpFileName(), "strict ", false, false, false, true, false, false) { + @Override + public void writeUnsatisfied(final TLCState state, final Action action, final TLCState successor, + final SemanticNode pred, final tlc2.util.Context c) { + unsatisfied.incrementAndGet(); + super.writeUnsatisfied(state, action, successor, pred, c); + } + + @Override + public void writeState(final TLCState state, final TLCState successor, final short stateFlags, + final Action action, final SemanticNode pred) { + if (!successor.allAssigned()) { + partial.set(true); + } + super.writeState(state, successor, stateFlags, action, pred); + } + }; + } catch (IOException e) { + fail(e.getMessage()); + return null; + } + } + + @Test + public void testSpec() { + assertTrue(recorder.recorded(EC.TLC_FINISHED)); + assertFalse(recorder.recorded(EC.GENERAL)); + assertTrue(recorder.recordedWithStringValues(EC.TLC_STATS, "5", "4", "0")); + // The false guards were reported (A, B and C are each blocked + // somewhere) but never forwarded with unassigned variables. + assertTrue(unsatisfied.get() > 0); + assertFalse(partial.get()); + } +}