diff --git a/CHANGELOG.md b/CHANGELOG.md index eb40a1f..8660b28 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,20 @@ platform API version. it and what it feeds, leaving its members as they are. Drawn as GLSP feedback, so it survives a relayout or an edit. +### Changed + +- **The Java ELK server is rebuilt on ELK 0.10.0, and elkjs is pinned to + 0.10.0**, so a graph lays out the same on either engine; before, the + server's ELK 0.8.1 put some graphs' nodes in another order than elkjs 0.9.3. + The server's source, recovered from the shipped jar, now lives in + `elk-server/` (Maven, `mvn package`) under the EPL-2.0 only, as before, its + licence now inside the jar too; wire format unchanged (the gzip-framed + socket mode included). `test/elk-server-parity.test.ts` lays two real + documents out on both engines and requires them to match. **Products must + rename the jar they copy and name in their profile to + `elk-server-0.3.0-all.jar`**, and the server now needs **Java 17** (ELK 0.10 + is built for it); without it layouts stay on elkjs, as before. + ### Tooling - `scripts/render-svg --select ` selects an element (the diagram's id or diff --git a/elk-server/.gitignore b/elk-server/.gitignore new file mode 100644 index 0000000..2f7896d --- /dev/null +++ b/elk-server/.gitignore @@ -0,0 +1 @@ +target/ diff --git a/elk-server/LICENSE b/elk-server/LICENSE new file mode 100644 index 0000000..df8aa0c --- /dev/null +++ b/elk-server/LICENSE @@ -0,0 +1,292 @@ +The ELK layout server (the io.typefox.elkserver classes, their source in the +elk-server/ directory of Dialogram, and the jar built from them) is made +available under the terms of the Eclipse Public License v. 2.0 only, the +licence of Eclipse ELK and of the server it was recovered from, which is +available at https://www.eclipse.org/legal/epl-2.0. The rest of Dialogram is +also offered under the GNU GPL 2.0 with the Classpath Exception as a Secondary +License; the server is not. + +The libraries bundled in the jar keep their own licences: Eclipse ELK, EMF and +the Xtend and Xbase libraries under the EPL-2.0 (about*.html); Guava, Gson, +JSpecify and the Error Prone and J2ObjC annotations under the Apache License +2.0 (META-INF/LICENSE). + +SPDX-License-Identifier: EPL-2.0 + +# Eclipse Public License - v 2.0 + + THE ACCOMPANYING PROGRAM IS PROVIDED UNDER THE TERMS OF THIS ECLIPSE + PUBLIC LICENSE ("AGREEMENT"). ANY USE, REPRODUCTION OR DISTRIBUTION + OF THE PROGRAM CONSTITUTES RECIPIENT'S ACCEPTANCE OF THIS AGREEMENT. + + 1. DEFINITIONS + + "Contribution" means: + + a) in the case of the initial Contributor, the initial content + Distributed under this Agreement, and + + b) in the case of each subsequent Contributor: + i) changes to the Program, and + ii) additions to the Program; + where such changes and/or additions to the Program originate from + and are Distributed by that particular Contributor. A Contribution + "originates" from a Contributor if it was added to the Program by + such Contributor itself or anyone acting on such Contributor's behalf. + Contributions do not include changes or additions to the Program that + are not Modified Works. + + "Contributor" means any person or entity that Distributes the Program. + + "Licensed Patents" mean patent claims licensable by a Contributor which + are necessarily infringed by the use or sale of its Contribution alone + or when combined with the Program. + + "Program" means the Contributions Distributed in accordance with this + Agreement. + + "Recipient" means anyone who receives the Program under this Agreement + or any Secondary License (as applicable), including Contributors. + + "Derivative Works" shall mean any work, whether in Source Code or other + form, that is based on (or derived from) the Program and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. + + "Modified Works" shall mean any work in Source Code or other form that + results from an addition to, deletion from, or modification of the + contents of the Program, including, for purposes of clarity any new file + in Source Code form that contains any contents of the Program. Modified + Works shall not include works that contain only declarations, + interfaces, types, classes, structures, or files of the Program solely + in each case in order to link to, bind by name, or subclass the Program + or Modified Works thereof. + + "Distribute" means the acts of a) distributing or b) making available + in any manner that enables the transfer of a copy. + + "Source Code" means the form of a Program preferred for making + modifications, including but not limited to software source code, + documentation source, and configuration files. + + "Secondary License" means either the GNU General Public License, + Version 2.0, or any later versions of that license, including any + exceptions or additional permissions as identified by the initial + Contributor. + + 2. GRANT OF RIGHTS + + a) Subject to the terms of this Agreement, each Contributor hereby + grants Recipient a non-exclusive, worldwide, royalty-free copyright + license to reproduce, prepare Derivative Works of, publicly display, + publicly perform, Distribute and sublicense the Contribution of such + Contributor, if any, and such Derivative Works. + + b) Subject to the terms of this Agreement, each Contributor hereby + grants Recipient a non-exclusive, worldwide, royalty-free patent + license under Licensed Patents to make, use, sell, offer to sell, + import and otherwise transfer the Contribution of such Contributor, + if any, in Source Code or other form. This patent license shall + apply to the combination of the Contribution and the Program if, at + the time the Contribution is added by the Contributor, such addition + of the Contribution causes such combination to be covered by the + Licensed Patents. The patent license shall not apply to any other + combinations which include the Contribution. No hardware per se is + licensed hereunder. + + c) Recipient understands that although each Contributor grants the + licenses to its Contributions set forth herein, no assurances are + provided by any Contributor that the Program does not infringe the + patent or other intellectual property rights of any other entity. + Each Contributor disclaims any liability to Recipient for claims + brought by any other entity based on infringement of intellectual + property rights or otherwise. As a condition to exercising the + rights and licenses granted hereunder, each Recipient hereby + assumes sole responsibility to secure any other intellectual + property rights needed, if any. For example, if a third party + patent license is required to allow Recipient to Distribute the + Program, it is Recipient's responsibility to acquire that license + before distributing the Program. + + d) Each Contributor represents that to its knowledge it has + sufficient copyright rights in its Contribution, if any, to grant + the copyright license set forth in this Agreement. + + e) Notwithstanding the terms of any Secondary License, no + Contributor makes additional grants to any Recipient (other than + those set forth in this Agreement) as a result of such Recipient's + receipt of the Program under the terms of a Secondary License + (if permitted under the terms of Section 3). + + 3. REQUIREMENTS + + 3.1 If a Contributor Distributes the Program in any form, then: + + a) the Program must also be made available as Source Code, in + accordance with section 3.2, and the Contributor must accompany + the Program with a statement that the Source Code for the Program + is available under this Agreement, and informs Recipients how to + obtain it in a reasonable manner on or through a medium customarily + used for software exchange; and + + b) the Contributor may Distribute the Program under a license + different than this Agreement, provided that such license: + i) effectively disclaims on behalf of all other Contributors all + warranties and conditions, express and implied, including + warranties or conditions of title and non-infringement, and + implied warranties or conditions of merchantability and fitness + for a particular purpose; + + ii) effectively excludes on behalf of all other Contributors all + liability for damages, including direct, indirect, special, + incidental and consequential damages, such as lost profits; + + iii) does not attempt to limit or alter the recipients' rights + in the Source Code under section 3.2; and + + iv) requires any subsequent distribution of the Program by any + party to be under a license that satisfies the requirements + of this section 3. + + 3.2 When the Program is Distributed as Source Code: + + a) it must be made available under this Agreement, or if the + Program (i) is combined with other material in a separate file or + files made available under a Secondary License, and (ii) the initial + Contributor attached to the Source Code the notice described in + Exhibit A of this Agreement, then the Program may be made available + under the terms of such Secondary Licenses, and + + b) a copy of this Agreement must be included with each copy of + the Program. + + 3.3 Contributors may not remove or alter any copyright, patent, + trademark, attribution notices, disclaimers of warranty, or limitations + of liability ("notices") contained within the Program from any copy of + the Program which they Distribute, provided that Contributors may add + their own appropriate notices. + + 4. COMMERCIAL DISTRIBUTION + + Commercial distributors of software may accept certain responsibilities + with respect to end users, business partners and the like. While this + license is intended to facilitate the commercial use of the Program, + the Contributor who includes the Program in a commercial product + offering should do so in a manner which does not create potential + liability for other Contributors. Therefore, if a Contributor includes + the Program in a commercial product offering, such Contributor + ("Commercial Contributor") hereby agrees to defend and indemnify every + other Contributor ("Indemnified Contributor") against any losses, + damages and costs (collectively "Losses") arising from claims, lawsuits + and other legal actions brought by a third party against the Indemnified + Contributor to the extent caused by the acts or omissions of such + Commercial Contributor in connection with its distribution of the Program + in a commercial product offering. The obligations in this section do not + apply to any claims or Losses relating to any actual or alleged + intellectual property infringement. In order to qualify, an Indemnified + Contributor must: a) promptly notify the Commercial Contributor in + writing of such claim, and b) allow the Commercial Contributor to control, + and cooperate with the Commercial Contributor in, the defense and any + related settlement negotiations. The Indemnified Contributor may + participate in any such claim at its own expense. + + For example, a Contributor might include the Program in a commercial + product offering, Product X. That Contributor is then a Commercial + Contributor. If that Commercial Contributor then makes performance + claims, or offers warranties related to Product X, those performance + claims and warranties are such Commercial Contributor's responsibility + alone. Under this section, the Commercial Contributor would have to + defend claims against the other Contributors related to those performance + claims and warranties, and if a court requires any other Contributor to + pay any damages as a result, the Commercial Contributor must pay + those damages. + + 5. NO WARRANTY + + EXCEPT AS EXPRESSLY SET FORTH IN THIS AGREEMENT, AND TO THE EXTENT + PERMITTED BY APPLICABLE LAW, THE PROGRAM IS PROVIDED ON AN "AS IS" + BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, EITHER EXPRESS OR + IMPLIED INCLUDING, WITHOUT LIMITATION, ANY WARRANTIES OR CONDITIONS OF + TITLE, NON-INFRINGEMENT, MERCHANTABILITY OR FITNESS FOR A PARTICULAR + PURPOSE. Each Recipient is solely responsible for determining the + appropriateness of using and distributing the Program and assumes all + risks associated with its exercise of rights under this Agreement, + including but not limited to the risks and costs of program errors, + compliance with applicable laws, damage to or loss of data, programs + or equipment, and unavailability or interruption of operations. + + 6. DISCLAIMER OF LIABILITY + + EXCEPT AS EXPRESSLY SET FORTH IN THIS AGREEMENT, AND TO THE EXTENT + PERMITTED BY APPLICABLE LAW, NEITHER RECIPIENT NOR ANY CONTRIBUTORS + SHALL HAVE ANY LIABILITY FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, + EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING WITHOUT LIMITATION LOST + PROFITS), HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + ARISING IN ANY WAY OUT OF THE USE OR DISTRIBUTION OF THE PROGRAM OR THE + EXERCISE OF ANY RIGHTS GRANTED HEREUNDER, EVEN IF ADVISED OF THE + POSSIBILITY OF SUCH DAMAGES. + + 7. GENERAL + + If any provision of this Agreement is invalid or unenforceable under + applicable law, it shall not affect the validity or enforceability of + the remainder of the terms of this Agreement, and without further + action by the parties hereto, such provision shall be reformed to the + minimum extent necessary to make such provision valid and enforceable. + + If Recipient institutes patent litigation against any entity + (including a cross-claim or counterclaim in a lawsuit) alleging that the + Program itself (excluding combinations of the Program with other software + or hardware) infringes such Recipient's patent(s), then such Recipient's + rights granted under Section 2(b) shall terminate as of the date such + litigation is filed. + + All Recipient's rights under this Agreement shall terminate if it + fails to comply with any of the material terms or conditions of this + Agreement and does not cure such failure in a reasonable period of + time after becoming aware of such noncompliance. If all Recipient's + rights under this Agreement terminate, Recipient agrees to cease use + and distribution of the Program as soon as reasonably practicable. + However, Recipient's obligations under this Agreement and any licenses + granted by Recipient relating to the Program shall continue and survive. + + Everyone is permitted to copy and distribute copies of this Agreement, + but in order to avoid inconsistency the Agreement is copyrighted and + may only be modified in the following manner. The Agreement Steward + reserves the right to publish new versions (including revisions) of + this Agreement from time to time. No one other than the Agreement + Steward has the right to modify this Agreement. The Eclipse Foundation + is the initial Agreement Steward. The Eclipse Foundation may assign the + responsibility to serve as the Agreement Steward to a suitable separate + entity. Each new version of the Agreement will be given a distinguishing + version number. The Program (including Contributions) may always be + Distributed subject to the version of the Agreement under which it was + received. In addition, after a new version of the Agreement is published, + Contributor may elect to Distribute the Program (including its + Contributions) under the new version. + + Except as expressly stated in Sections 2(a) and 2(b) above, Recipient + receives no rights or licenses to the intellectual property of any + Contributor under this Agreement, whether expressly, by implication, + estoppel or otherwise. All rights in the Program not expressly granted + under this Agreement are reserved. Nothing in this Agreement is intended + to be enforceable by any entity that is not a Contributor or Recipient. + No third-party beneficiary rights are created under this Agreement. + + Exhibit A - Form of Secondary Licenses Notice + + "This Source Code may also be made available under the following + Secondary Licenses when the conditions for such availability set forth + in the Eclipse Public License, v. 2.0 are satisfied: {name license(s), + version(s), and exceptions or additional permissions here}." + + Simply including a copy of this Agreement, including this Exhibit A + is not sufficient to license the Source Code under Secondary Licenses. + + If it is not possible or desirable to put the notice in a particular + file, then You may include the notice in a location (such as a LICENSE + file in a relevant directory) where a recipient would be likely to + look for such a notice. + + You may add additional accurate notices of copyright ownership. diff --git a/elk-server/README.md b/elk-server/README.md new file mode 100644 index 0000000..049d28e --- /dev/null +++ b/elk-server/README.md @@ -0,0 +1,73 @@ +# elk-server + +The Java ELK layout server the diagram server sends large graphs to +(`packages/diagram-server/src/server/elk-server/`). It is TypeFox's elk-server +(`io.typefox.elkserver`) with a gzip-framed socket mode added for +ascpp-graph-sprotty (its commit `af9cc65`). + +## Where the source comes from + +Only the built jar was ever committed anywhere (`elk-server-0.2.0-all.jar`, in +ascpp-graph-sprotty, mlir-viewer and here). These six classes were recovered +from it with the CFR decompiler (0.152), and are otherwise as the jar had them: +the same logic, wire format and log lines; CFR's expanded `try` blocks were +written back as try-with-resources. + +The recovery was checked by building it on the ELK the jar bundled +(`-Delk.version=0.8.1 -Dmaven.compiler.release=11`) and laying out the graphs +the platform builds for six documents (a chain whose every id holds braces, two +grouped specification documents, a 148-node one, two HDL modules) on both jars +through the platform's gzip-framed client: every node, port and route came out +identical. + +## The protocol + +`java -jar elk-server--all.jar --socket` listens on +`ELK_SERVER_HOST`:`ELK_SERVER_PORT` (or the `elk.server.host` and +`elk.server.port` system properties; default localhost:5008) and logs +`ELK Gzip-Framed Server listening on /
:` when ready. + +Each connection is framed or not by its first bytes: + +- **Framed** (the only one the platform uses): every message, both ways, is the + magic `GZ10`, the body's length in 4 bytes big-endian, and the body: gzip + JSON. A request is an ELK JSON graph; the answer maps each element id to + `{ position, size }`, or an edge id to `{ route }`, or is an error + `{ message, name, stack }`. A body that is not gzip fails the connection + without an answer, so a client compresses every request, however small. +- **Legacy**: plain JSON graphs back to back, found by counting braces, those + in strings included. A brace inside an id leaves it waiting forever. + +`--stdio` serves the legacy form on stdin and stdout. + +## Building + +```bash +mvn -q package # target/elk-server-0.3.0-all.jar +cp target/elk-server-0.3.0-all.jar ../packages/diagram-server/resources/elk-server/ +``` + +Maven 3.9 and a JDK 17. The shade plugin merges every dependency into the one +jar, keeping each ELK bundle's list of layout providers. + +## ELK and elkjs + +The server is built on the ELK the platform's elkjs is: ELK 0.10.0 and elkjs +0.10.0 lay every graph the parity test holds them to out alike, node for node +and route for route. elkjs 0.9.x matched no ELK release (0.9.1 and 0.10.0 each +differed on some graphs), which is why elkjs is pinned to an exact version. +To move to another ELK, raise `elk.version` here and `elkjs` in +`packages/diagram-server/package.json` to the same release, rebuild, copy the +jar, and run `test/elk-server-parity.test.ts`. + +ELK 0.10 is built for Java 17, so the server needs Java 17 or later. + +## Licence + +The Eclipse Public License 2.0 only, like ELK and the server this was recovered +from: this directory and the jar are not under the GPL secondary licence the +rest of the repository is also offered under. The text is in [`LICENSE`](LICENSE), +each source file says so (`SPDX-License-Identifier: EPL-2.0`), and the build +puts the same file in the jar at `META-INF/elk-server/LICENSE`, beside the +bundled libraries' own notices (`META-INF/LICENSE` there is Guava's Apache +License). diff --git a/elk-server/pom.xml b/elk-server/pom.xml new file mode 100644 index 0000000..a1ce824 --- /dev/null +++ b/elk-server/pom.xml @@ -0,0 +1,137 @@ + + + + 4.0.0 + + io.typefox + elk-server + 0.3.0 + jar + + + + + EPL-2.0 + https://www.eclipse.org/legal/epl-2.0/ + + + + + + 0.10.0 + 2.10 + + 17 + UTF-8 + + + + + org.eclipse.elk + org.eclipse.elk.core + ${elk.version} + + + org.eclipse.elk + org.eclipse.elk.graph + ${elk.version} + + + org.eclipse.elk + org.eclipse.elk.graph.json + ${elk.version} + + + org.eclipse.elk + org.eclipse.elk.alg.layered + ${elk.version} + + + org.eclipse.elk + org.eclipse.elk.alg.force + ${elk.version} + + + org.eclipse.elk + org.eclipse.elk.alg.mrtree + ${elk.version} + + + com.google.code.gson + gson + ${gson.version} + + + + + + + + ${project.basedir} + + LICENSE + + META-INF/elk-server + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.13.0 + + + org.apache.maven.plugins + maven-shade-plugin + 3.6.0 + + + package + + shade + + + elk-server-${project.version}-all + false + + + + *:* + + META-INF/*.SF + META-INF/*.DSA + META-INF/*.RSA + + + + + + + + io.typefox.elkserver.ElkServerApplication + + ${project.version} + ${elk.version} + EPL-2.0 + + + + + + + + + + diff --git a/elk-server/src/main/java/io/typefox/elkserver/AbstractElkServer.java b/elk-server/src/main/java/io/typefox/elkserver/AbstractElkServer.java new file mode 100644 index 0000000..d670eaf --- /dev/null +++ b/elk-server/src/main/java/io/typefox/elkserver/AbstractElkServer.java @@ -0,0 +1,124 @@ +// SPDX-License-Identifier: EPL-2.0 +package io.typefox.elkserver; + +import com.google.gson.Gson; +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import org.eclipse.elk.alg.force.options.ForceMetaDataProvider; +import org.eclipse.elk.alg.layered.options.LayeredMetaDataProvider; +import org.eclipse.elk.alg.mrtree.options.MrTreeMetaDataProvider; +import org.eclipse.elk.core.data.LayoutMetaDataService; +import org.eclipse.elk.graph.ElkBendPoint; +import org.eclipse.elk.graph.ElkEdge; +import org.eclipse.elk.graph.ElkEdgeSection; +import org.eclipse.elk.graph.ElkLabel; +import org.eclipse.elk.graph.ElkNode; +import org.eclipse.elk.graph.ElkPort; +import org.eclipse.elk.graph.ElkShape; +import org.eclipse.elk.graph.json.ElkGraphJson; + +/** A layout server: reads ELK JSON graphs, lays them out, answers with the positions, sizes and routes. */ +public abstract class AbstractElkServer { + + private static final int STRING_BUILDER_CAPACITY = 262144; + + public void start() { + initialize(); + try { + run(); + } catch (Throwable thr) { + thr.printStackTrace(); + System.exit(1); + } + } + + protected void initialize() { + LayoutMetaDataService.getInstance().registerLayoutMetaDataProviders( + new LayeredMetaDataProvider(), new ForceMetaDataProvider(), new MrTreeMetaDataProvider()); + } + + protected abstract void run() throws Exception; + + /** + * One graph from a stream of concatenated JSON objects, found by counting + * braces, those inside strings included: a brace in an id leaves it waiting. + * The framed socket mode does not use it. + */ + protected ElkNode readGraph(InputStream input) throws IOException { + int objLevel = 0; + StringBuilder strBuilder = new StringBuilder(STRING_BUILDER_CAPACITY); + do { + int ch = input.read(); + if (ch < 0) { + return null; + } + if (ch == '{') { + objLevel++; + } else if (ch == '}') { + objLevel--; + } + strBuilder.append((char) ch); + } while (objLevel > 0); + return ElkGraphJson.forGraph(strBuilder.toString()).toElk(); + } + + protected void writeLayoutData(ElkNode graph, Appendable output, Gson gson) { + Map data = new LinkedHashMap<>(); + computeLayoutData(graph, data); + gson.toJson(data, output); + } + + protected void computeLayoutData(ElkNode node, Map data) { + if (node.getIdentifier() != null) { + data.put(node.getIdentifier(), getShapeLayout(node)); + } + for (ElkLabel label : node.getLabels()) { + if (label.getIdentifier() != null) { + data.put(label.getIdentifier(), getShapeLayout(label)); + } + } + for (ElkPort port : node.getPorts()) { + if (port.getIdentifier() != null) { + data.put(port.getIdentifier(), getShapeLayout(port)); + } + for (ElkLabel label : port.getLabels()) { + if (label.getIdentifier() != null) { + data.put(label.getIdentifier(), getShapeLayout(label)); + } + } + } + for (ElkEdge edge : node.getContainedEdges()) { + if (edge.getIdentifier() != null) { + data.put(edge.getIdentifier(), getEdgeLayout(edge)); + } + } + for (ElkNode child : node.getChildren()) { + computeLayoutData(child, data); + } + } + + protected JsonObjects.ShapeLayoutElement getShapeLayout(ElkShape shape) { + JsonObjects.Point position = new JsonObjects.Point(shape.getX(), shape.getY()); + JsonObjects.Dimension size = new JsonObjects.Dimension(shape.getWidth(), shape.getHeight()); + return new JsonObjects.ShapeLayoutElement(position, size); + } + + protected JsonObjects.EdgeLayoutElement getEdgeLayout(ElkEdge edge) { + if (edge.getSections().isEmpty()) { + return new JsonObjects.EdgeLayoutElement(Collections.emptyList()); + } + ElkEdgeSection section = edge.getSections().get(0); + List routingPoints = new ArrayList<>(); + routingPoints.add(new JsonObjects.Point(section.getStartX(), section.getStartY())); + for (ElkBendPoint bendPoint : section.getBendPoints()) { + routingPoints.add(new JsonObjects.Point(bendPoint.getX(), bendPoint.getY())); + } + routingPoints.add(new JsonObjects.Point(section.getEndX(), section.getEndY())); + return new JsonObjects.EdgeLayoutElement(routingPoints); + } +} diff --git a/elk-server/src/main/java/io/typefox/elkserver/ElkServerApplication.java b/elk-server/src/main/java/io/typefox/elkserver/ElkServerApplication.java new file mode 100644 index 0000000..1c37e58 --- /dev/null +++ b/elk-server/src/main/java/io/typefox/elkserver/ElkServerApplication.java @@ -0,0 +1,34 @@ +// SPDX-License-Identifier: EPL-2.0 +/* + * The ELK layout server: TypeFox's elk-server 0.2.0, with the gzip-framed socket + * mode added for ascpp-graph-sprotty (af9cc65). Recovered from the shipped + * elk-server-0.2.0-all.jar (decompiled with CFR 0.152), its logic, wire format and + * log lines unchanged; see ../../../../../../README.md. + */ +package io.typefox.elkserver; + +import java.util.Arrays; +import java.util.List; + +public class ElkServerApplication { + + public static void main(String[] args) { + List argsList = Arrays.asList(args); + boolean stdio = argsList.contains("--stdio"); + boolean socket = argsList.contains("--socket"); + if (!stdio && !socket) { + System.err.println("The server must be started with either --stdio or --socket argument."); + System.exit(1); + } + if (stdio && socket) { + System.err.println("The --stdio and --socket arguments cannot be used at the same time."); + System.exit(1); + } + if (stdio) { + new StdioElkServer().start(); + } + if (socket) { + new GzipFramedSocketElkServer().start(); + } + } +} diff --git a/elk-server/src/main/java/io/typefox/elkserver/GzipFramedSocketElkServer.java b/elk-server/src/main/java/io/typefox/elkserver/GzipFramedSocketElkServer.java new file mode 100644 index 0000000..52db8c5 --- /dev/null +++ b/elk-server/src/main/java/io/typefox/elkserver/GzipFramedSocketElkServer.java @@ -0,0 +1,217 @@ +// SPDX-License-Identifier: EPL-2.0 +package io.typefox.elkserver; + +import com.google.gson.Gson; +import java.io.BufferedInputStream; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.EOFException; +import java.io.InputStream; +import java.io.OutputStream; +import java.io.OutputStreamWriter; +import java.io.StringWriter; +import java.net.InetSocketAddress; +import java.nio.channels.AsynchronousServerSocketChannel; +import java.nio.channels.AsynchronousSocketChannel; +import java.nio.channels.Channels; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.logging.Level; +import java.util.logging.Logger; +import java.util.zip.GZIPInputStream; +import java.util.zip.GZIPOutputStream; +import org.eclipse.elk.core.RecursiveGraphLayoutEngine; +import org.eclipse.elk.core.util.BasicProgressMonitor; +import org.eclipse.elk.graph.ElkNode; +import org.eclipse.elk.graph.json.ElkGraphJson; + +/** + * The socket server, in two framings, chosen per connection by its first bytes. + * + * Framed: every message, both ways, is the magic {@code GZ10}, the length of the + * body (4 bytes, big-endian) and the body, gzip-compressed JSON. The server never + * looks inside a body to find where it ends, so any id is safe. A body that is not + * gzip fails the connection without an answer. + * + * Legacy: concatenated plain JSON objects, found by counting braces (see + * {@link AbstractElkServer#readGraph}). + * + * Host and port: the {@code elk.server.host} and {@code elk.server.port} system + * properties, else the {@code ELK_SERVER_HOST} and {@code ELK_SERVER_PORT} + * environment variables, else localhost:5008. The server is ready once it logs + * "ELK Gzip-Framed Server listening on host/address:port". + */ +public class GzipFramedSocketElkServer extends AbstractElkServer { + + private static final Logger LOG = Logger.getLogger(GzipFramedSocketElkServer.class.getName()); + + private static final byte[] MAGIC = new byte[] { 'G', 'Z', '1', '0' }; + private static final int HEADER_LEN = 8; + + private int port; + private String host; + + public GzipFramedSocketElkServer() { + this.port = Integer.getInteger("elk.server.port", + Integer.parseInt(System.getenv().getOrDefault("ELK_SERVER_PORT", "5008"))); + this.host = System.getProperty("elk.server.host", System.getenv().getOrDefault("ELK_SERVER_HOST", "localhost")); + } + + public GzipFramedSocketElkServer(String host, int port) { + this.host = host; + this.port = port; + } + + @Override + protected void run() throws Exception { + ExecutorService executor = Executors.newCachedThreadPool(); + InetSocketAddress address = new InetSocketAddress(host, port); + try (AsynchronousServerSocketChannel serverSocket = AsynchronousServerSocketChannel.open().bind(address)) { + LOG.info("ELK Gzip-Framed Server listening on " + address); + int clientSeq = 0; + while (true) { + AsynchronousSocketChannel channel = serverSocket.accept().get(); + int clientNo = clientSeq++; + LOG.info("[" + clientNo + "] Accepted connection"); + executor.submit(() -> { + try { + handleConnection(channel, clientNo); + LOG.info("[" + clientNo + "] Closed connection"); + } catch (Throwable t) { + LOG.log(Level.SEVERE, "[" + clientNo + "] Error: " + t.getMessage(), t); + } + }); + } + } finally { + executor.shutdown(); + } + } + + private void handleConnection(AsynchronousSocketChannel channel, int clientNo) throws Exception { + InputStream rawIn = Channels.newInputStream(channel); + OutputStream out = Channels.newOutputStream(channel); + BufferedInputStream in = new BufferedInputStream(rawIn); + Gson gson = new Gson(); + RecursiveGraphLayoutEngine engine = new RecursiveGraphLayoutEngine(); + + Mode mode = detectMode(in); + LOG.info("[" + clientNo + "] Mode = " + mode); + + if (mode == Mode.FRAMED) { + byte[] jsonBytes; + while ((jsonBytes = readFramedRequest(in)) != null) { + LOG.info("[" + clientNo + "] Received framed+gzip JSON (" + jsonBytes.length + " bytes uncompressed)"); + ElkNode graph; + try { + graph = ElkGraphJson.forGraph(new String(jsonBytes, StandardCharsets.UTF_8)).toElk(); + } catch (Exception parse) { + LOG.log(Level.SEVERE, "[" + clientNo + "] Parse error", parse); + sendFramed(out, gson.toJson(new JsonObjects.Error(parse)).getBytes(StandardCharsets.UTF_8)); + continue; + } + try { + engine.layout(graph, new BasicProgressMonitor()); + StringWriter sw = new StringWriter(); + writeLayoutData(graph, sw, gson); + byte[] response = sw.toString().getBytes(StandardCharsets.UTF_8); + sendFramed(out, response); + LOG.info("[" + clientNo + "] Sent framed+gzip layout (" + response.length + " bytes uncompressed)"); + } catch (Exception layoutErr) { + LOG.log(Level.SEVERE, "[" + clientNo + "] Layout error", layoutErr); + sendFramed(out, gson.toJson(new JsonObjects.Error(layoutErr)).getBytes(StandardCharsets.UTF_8)); + } + } + } else { + ElkNode graph; + while ((graph = readGraph(in)) != null) { + LOG.info("[" + clientNo + "] Received legacy JSON graph"); + try { + engine.layout(graph, new BasicProgressMonitor()); + OutputStreamWriter writer = new OutputStreamWriter(out, StandardCharsets.UTF_8); + writeLayoutData(graph, writer, gson); + writer.flush(); + LOG.info("[" + clientNo + "] Sent legacy JSON layout"); + } catch (Exception exc) { + OutputStreamWriter writer = new OutputStreamWriter(out, StandardCharsets.UTF_8); + gson.toJson(new JsonObjects.Error(exc), writer); + writer.flush(); + LOG.log(Level.SEVERE, "[" + clientNo + "] Layout error", exc); + } + } + } + } + + /** Framed when the connection opens with the magic; the bytes stay unread either way. */ + private Mode detectMode(BufferedInputStream in) throws Exception { + in.mark(HEADER_LEN); + byte[] probe = in.readNBytes(HEADER_LEN); + if (probe.length == 0) { + return Mode.LEGACY; + } + if (probe.length < HEADER_LEN) { + in.reset(); + return Mode.LEGACY; + } + boolean magic = hasMagic(probe); + in.reset(); + return magic ? Mode.FRAMED : Mode.LEGACY; + } + + /** The next request's JSON, uncompressed; null at the end of the stream. */ + private byte[] readFramedRequest(BufferedInputStream in) throws Exception { + in.mark(HEADER_LEN); + byte[] header = in.readNBytes(HEADER_LEN); + if (header.length == 0) { + return null; + } + if (header.length < HEADER_LEN) { + throw new EOFException("Truncated header"); + } + if (!hasMagic(header)) { + throw new IllegalStateException("Expected magic header for framed request"); + } + int length = (header[4] & 0xFF) << 24 | (header[5] & 0xFF) << 16 | (header[6] & 0xFF) << 8 | header[7] & 0xFF; + if (length < 0) { + throw new IllegalArgumentException("Negative length"); + } + byte[] comp = in.readNBytes(length); + if (comp.length != length) { + throw new EOFException("Truncated compressed payload"); + } + try (GZIPInputStream gis = new GZIPInputStream(new ByteArrayInputStream(comp))) { + return gis.readAllBytes(); + } + } + + private void sendFramed(OutputStream out, byte[] rawJson) throws Exception { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + try (GZIPOutputStream gzos = new GZIPOutputStream(baos)) { + gzos.write(rawJson); + } + byte[] comp = baos.toByteArray(); + byte[] header = new byte[HEADER_LEN]; + System.arraycopy(MAGIC, 0, header, 0, MAGIC.length); + int len = comp.length; + header[4] = (byte) (len >>> 24); + header[5] = (byte) (len >>> 16); + header[6] = (byte) (len >>> 8); + header[7] = (byte) len; + out.write(header); + out.write(comp); + out.flush(); + } + + private static boolean hasMagic(byte[] bytes) { + return bytes[0] == MAGIC[0] && bytes[1] == MAGIC[1] && bytes[2] == MAGIC[2] && bytes[3] == MAGIC[3]; + } + + public static void main(String[] args) { + new GzipFramedSocketElkServer().start(); + } + + private enum Mode { + FRAMED, + LEGACY + } +} diff --git a/elk-server/src/main/java/io/typefox/elkserver/JsonObjects.java b/elk-server/src/main/java/io/typefox/elkserver/JsonObjects.java new file mode 100644 index 0000000..4fee74c --- /dev/null +++ b/elk-server/src/main/java/io/typefox/elkserver/JsonObjects.java @@ -0,0 +1,68 @@ +// SPDX-License-Identifier: EPL-2.0 +package io.typefox.elkserver; + +import java.io.PrintWriter; +import java.io.StringWriter; +import java.util.List; + +/** The answer's JSON shapes: per element id, a position and size, or an edge's route; or an error. */ +public final class JsonObjects { + + private JsonObjects() { + } + + public interface LayoutElement { + } + + public static class ShapeLayoutElement implements LayoutElement { + public Point position; + public Dimension size; + + public ShapeLayoutElement(Point position, Dimension size) { + this.position = position; + this.size = size; + } + } + + public static class EdgeLayoutElement implements LayoutElement { + public List route; + + public EdgeLayoutElement(List route) { + this.route = route; + } + } + + public static class Point { + public double x; + public double y; + + public Point(double x, double y) { + this.x = x; + this.y = y; + } + } + + public static class Dimension { + public double width; + public double height; + + public Dimension(double width, double height) { + this.width = width; + this.height = height; + } + } + + public static class Error { + public String message; + public String name; + public String stack; + + public Error(Exception exception) { + this.message = exception.getMessage(); + this.name = exception.getClass().getSimpleName(); + StringWriter sw = new StringWriter(); + exception.printStackTrace(new PrintWriter(sw)); + this.stack = sw.toString(); + } + } +} diff --git a/elk-server/src/main/java/io/typefox/elkserver/SocketElkServer.java b/elk-server/src/main/java/io/typefox/elkserver/SocketElkServer.java new file mode 100644 index 0000000..e5e5be9 --- /dev/null +++ b/elk-server/src/main/java/io/typefox/elkserver/SocketElkServer.java @@ -0,0 +1,74 @@ +// SPDX-License-Identifier: EPL-2.0 +package io.typefox.elkserver; + +import com.google.gson.Gson; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStreamWriter; +import java.net.InetSocketAddress; +import java.nio.channels.AsynchronousServerSocketChannel; +import java.nio.channels.AsynchronousSocketChannel; +import java.nio.channels.Channels; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.logging.Logger; +import org.eclipse.elk.core.RecursiveGraphLayoutEngine; +import org.eclipse.elk.core.util.BasicProgressMonitor; +import org.eclipse.elk.graph.ElkNode; + +/** + * TypeFox's original socket server: plain JSON on localhost:5008. No longer + * started by {@link ElkServerApplication}, whose {@code --socket} runs + * {@link GzipFramedSocketElkServer}; kept as the jar had it. + */ +public class SocketElkServer extends AbstractElkServer { + + protected final Logger logger = Logger.getLogger(SocketElkServer.class.getName()); + + @Override + protected void run() throws Exception { + ExecutorService executorService = Executors.newCachedThreadPool(); + InetSocketAddress address = new InetSocketAddress("localhost", 5008); + try (AsynchronousServerSocketChannel serverSocket = AsynchronousServerSocketChannel.open().bind(address)) { + logger.info("ELK Server listening to " + address); + int nextClientNo = 0; + while (true) { + AsynchronousSocketChannel socketChannel = serverSocket.accept().get(); + int clientNo = nextClientNo++; + logger.info("[" + clientNo + "] Accepted new connection."); + executorService.submit(() -> { + try { + handle(socketChannel, clientNo); + logger.info("[" + clientNo + "] Closed connection."); + } catch (Exception exc) { + logger.severe("[" + clientNo + "]" + exc.getMessage()); + exc.printStackTrace(); + } + }); + } + } finally { + executorService.shutdown(); + } + } + + protected void handle(AsynchronousSocketChannel socketChannel, int clientNo) throws IOException { + InputStream input = Channels.newInputStream(socketChannel); + OutputStreamWriter writer = new OutputStreamWriter(Channels.newOutputStream(socketChannel), StandardCharsets.UTF_8); + RecursiveGraphLayoutEngine engine = new RecursiveGraphLayoutEngine(); + Gson gson = new Gson(); + ElkNode graph; + while ((graph = readGraph(input)) != null) { + logger.info("[" + clientNo + "] Received input graph."); + try { + engine.layout(graph, new BasicProgressMonitor()); + logger.info("[" + clientNo + "] Computed layout."); + writeLayoutData(graph, writer, gson); + writer.flush(); + logger.info("[" + clientNo + "] Sent JSON data."); + } catch (Exception exc) { + gson.toJson(new JsonObjects.Error(exc), writer); + } + } + } +} diff --git a/elk-server/src/main/java/io/typefox/elkserver/StdioElkServer.java b/elk-server/src/main/java/io/typefox/elkserver/StdioElkServer.java new file mode 100644 index 0000000..522be07 --- /dev/null +++ b/elk-server/src/main/java/io/typefox/elkserver/StdioElkServer.java @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: EPL-2.0 +package io.typefox.elkserver; + +import com.google.gson.Gson; +import org.eclipse.elk.core.RecursiveGraphLayoutEngine; +import org.eclipse.elk.core.util.BasicProgressMonitor; +import org.eclipse.elk.graph.ElkNode; + +/** Plain JSON graphs on stdin, answers on stdout ({@code --stdio}). */ +public class StdioElkServer extends AbstractElkServer { + + @Override + protected void run() throws Exception { + RecursiveGraphLayoutEngine engine = new RecursiveGraphLayoutEngine(); + Gson gson = new Gson(); + ElkNode graph; + while ((graph = readGraph(System.in)) != null) { + try { + engine.layout(graph, new BasicProgressMonitor()); + writeLayoutData(graph, System.out, gson); + } catch (Exception exc) { + gson.toJson(new JsonObjects.Error(exc), System.out); + } + } + } +} diff --git a/package-lock.json b/package-lock.json index 37a10d6..6b009e3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3591,9 +3591,9 @@ "license": "MIT" }, "node_modules/elkjs": { - "version": "0.9.3", - "resolved": "https://registry.npmjs.org/elkjs/-/elkjs-0.9.3.tgz", - "integrity": "sha512-f/ZeWvW/BCXbhGEf1Ujp29EASo/lk1FDnETgNKwJrsVvGZhUWCZyg3xLJjAsxfOmt8KjswHmI5EwCQcPMpOYhQ==", + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/elkjs/-/elkjs-0.10.0.tgz", + "integrity": "sha512-v/3r+3Bl2NMrWmVoRTMBtHtWvRISTix/s9EfnsfEWApNrsmNjqgqJOispCGg46BPwIFdkag3N/HYSxJczvCm6w==", "license": "EPL-2.0" }, "node_modules/emoji-regex": { @@ -8345,7 +8345,7 @@ "@eclipse-glsp/server": "2.7.0", "@eclipse-glsp/server-mcp": "2.7.0", "@eclipse-glsp/vscode-integration": "2.7.0", - "elkjs": "^0.9.3", + "elkjs": "0.10.0", "inversify": "^6.0.2", "mathjs": "^15.1.0", "reflect-metadata": "^0.2.2", diff --git a/packages/diagram-server/package.json b/packages/diagram-server/package.json index cdb105e..c08d37f 100644 --- a/packages/diagram-server/package.json +++ b/packages/diagram-server/package.json @@ -21,7 +21,7 @@ "@eclipse-glsp/server": "2.7.0", "@eclipse-glsp/server-mcp": "2.7.0", "@eclipse-glsp/vscode-integration": "2.7.0", - "elkjs": "^0.9.3", + "elkjs": "0.10.0", "inversify": "^6.0.2", "mathjs": "^15.1.0", "reflect-metadata": "^0.2.2", diff --git a/packages/diagram-server/resources/elk-server/README.md b/packages/diagram-server/resources/elk-server/README.md index d43a039..b13f022 100644 --- a/packages/diagram-server/resources/elk-server/README.md +++ b/packages/diagram-server/resources/elk-server/README.md @@ -1,27 +1,31 @@ # ELK server -`elk-server-0.2.0-all.jar` is the Java ELK layout server the diagram server +`elk-server-0.3.0-all.jar` is the Java ELK layout server the diagram server uses for large graphs (`src/server/elk-server/`). A product ships it by copying this file into its extension and naming it in its profile -(`elkServer: { jar: 'resources/elk-server/elk-server-0.2.0-all.jar' }`). +(`elkServer: { jar: 'resources/elk-server/elk-server-0.3.0-all.jar' }`). -- What it is: TypeFox's elk-server 0.2.0 (`io.typefox.elkserver`), with a - gzip-framed socket mode added, built as one fat jar. It bundles Eclipse ELK - **0.8.1**; the platform's elkjs is 0.9.3, so the two engines can lay the - same graph out differently. A rebuild on the ELK that elkjs carries is - planned once the sources are located. -- Where it comes from: byte-identical to mlir-viewer's - `packages/vscode/resources/elk-server/elk-server-0.2.0/` and to - ascpp-graph-sprotty's `resources/elk-server/elk-server-0.2.0/` (commit - `af9cc65`, which added the framing). -- SHA-256: `7018ac49eec47197b91f70bc5ce19754f64cad70209bf6e5c678c670ec96407d` -- Needs: Java 11 or later (class file version 55) on the machine. Without - Java every layout stays on elkjs. -- Run: `java -jar elk-server-0.2.0-all.jar --socket`, port from the +- What it is: TypeFox's elk-server (`io.typefox.elkserver`) with the + gzip-framed socket mode added for ascpp-graph-sprotty (`af9cc65`), built as + one fat jar from the source in [`elk-server/`](../../../../elk-server) at + the repository root, recovered from the 0.2.0 jar. +- ELK: **0.10.0**, the release the platform's elkjs (pinned at 0.10.0) is + built from, so the two engines lay a graph out alike; + `test/elk-server-parity.test.ts` holds them to it. Raise elkjs and the + jar's ELK together. +- SHA-256: `230f85d9db016031c0af74e3645dc90224de8a7abe4b4c64da12b90964de7da3` +- Needs: Java 17 or later (class file version 61) on the machine, since ELK + 0.10 is built for it. Without it every layout stays on elkjs. +- Run: `java -jar elk-server-0.3.0-all.jar --socket`, port from the `ELK_SERVER_PORT` environment variable. Talk to it only in the framed mode (`GZ10`, a 4-byte big-endian length, a gzip body, both ways): the plain JSON mode finds the end of a message by counting braces, and a brace inside an id hangs it. -- Licences: Eclipse ELK and the server under the Eclipse Public License 2.0; - the bundled Gson and Guava under the Apache License 2.0 (their notices are - inside the jar). +- Licences: the server under the Eclipse Public License 2.0 only, not the + platform's GPL secondary licence (its text in the jar at + `META-INF/elk-server/LICENSE`, and in [`elk-server/LICENSE`](../../../../elk-server/LICENSE)); + the bundled ELK, EMF and Xtend libraries under the EPL-2.0 too, and Guava, + Gson and their annotation libraries under the Apache License 2.0 (their + notices are inside the jar). +- Before: `elk-server-0.2.0-all.jar`, the same server on ELK 0.8.1, which + laid some graphs out differently from elkjs 0.9.3 (node order, routes). diff --git a/packages/diagram-server/resources/elk-server/elk-server-0.2.0-all.jar b/packages/diagram-server/resources/elk-server/elk-server-0.3.0-all.jar similarity index 52% rename from packages/diagram-server/resources/elk-server/elk-server-0.2.0-all.jar rename to packages/diagram-server/resources/elk-server/elk-server-0.3.0-all.jar index 2fb9313..02c56e0 100644 Binary files a/packages/diagram-server/resources/elk-server/elk-server-0.2.0-all.jar and b/packages/diagram-server/resources/elk-server/elk-server-0.3.0-all.jar differ diff --git a/packages/diagram-server/src/server/elk-server/elk-server-process.ts b/packages/diagram-server/src/server/elk-server/elk-server-process.ts index 43b7ae6..3bdd285 100644 --- a/packages/diagram-server/src/server/elk-server/elk-server-process.ts +++ b/packages/diagram-server/src/server/elk-server/elk-server-process.ts @@ -110,7 +110,7 @@ export class ElkServerProcess { const listening = await this.waitForListening(child); if (listening === undefined) { this.stop(); - return this.failed(`the ELK server did not start within ${this.startTimeoutMs} ms (is Java 11 or later installed?)`); + return this.failed(`the ELK server did not start within ${this.startTimeoutMs} ms (is Java 17 or later installed?)`); } this.port = listening; this.lastFailure = undefined; diff --git a/packages/diagram-server/src/server/elk-server/switching-elk.ts b/packages/diagram-server/src/server/elk-server/switching-elk.ts index 6f560c0..fb25ecf 100644 --- a/packages/diagram-server/src/server/elk-server/switching-elk.ts +++ b/packages/diagram-server/src/server/elk-server/switching-elk.ts @@ -39,6 +39,9 @@ export function goesToServer(engine: ElkServerOptions['engine'], size: { nodes: return engine === 'auto' && (size.nodes > LAYOUT_TIER_MIDDLE_NODES || size.edges > LAYOUT_TIER_MIDDLE_EDGES); } +/** What `ELK.layout` answers a graph of type `T` with: the graph, laid out (elkjs's own declaration). */ +type LaidOut = Awaited> & Omit; + export class SwitchingElk implements ELK { constructor( private readonly elkjs: ELK, @@ -47,16 +50,17 @@ export class SwitchingElk implements ELK { private readonly layoutOnPort: typeof layoutOnServer = layoutOnServer ) {} - async layout(graph: ElkNode, args?: ElkLayoutArguments): Promise { + async layout(graph: T, args?: ElkLayoutArguments): Promise> { const size = elkGraphSize(graph); if (goesToServer(this.options.engine, size)) { const port = await this.server.ensureStarted(); if (port !== undefined) { const started = Date.now(); try { + // The server's layout is written into the graph it was given, as elkjs's is. const result = await this.layoutOnPort(port, graph, this.options.timeoutMs ?? DEFAULT_ELK_SERVER_TIMEOUT_MS); console.log(`[dialogram layout] ELK server: ${size.nodes} nodes, ${size.edges} edges in ${Date.now() - started} ms`); - return result; + return result as LaidOut; } catch (error) { console.warn(`[dialogram layout] ${error instanceof Error ? error.message : String(error)}; laying out with elkjs`); } diff --git a/packages/diagram-server/test/elk-server-parity.test.ts b/packages/diagram-server/test/elk-server-parity.test.ts new file mode 100644 index 0000000..c00aff5 --- /dev/null +++ b/packages/diagram-server/test/elk-server-parity.test.ts @@ -0,0 +1,111 @@ +/** + * The shipped ELK server and the platform's elkjs lay a graph out alike. + * + * A layout goes to the server past a size and to elkjs below it, so a product + * sees whichever engine took the graph; two engines that disagree would move a + * diagram's nodes as it grows past the threshold. The server is built on the + * ELK release elkjs is (ELK 0.10.0 and elkjs 0.10.0, see `elk-server/`): elkjs + * 0.9.x matched no ELK release, and the server's ELK 0.8.1 placed these two + * documents' nodes in another order. + * + * The graphs are the ones the platform's layout engine builds for two real + * documents, the two that told the engines apart: a specification document in + * two layer groups (`ndf diagram --group refinement`, outline and panel text + * trimmed) and an HDL module with glue logic (hdl-sidecar's `glue` golden). + * Each is laid out by elkjs and by the jar through the platform's own client + * (gzip-framed), and every node, port and route must match. + * + * Raising elkjs means rebuilding the jar on the same ELK; this test fails until + * both are raised. + */ +import 'reflect-metadata'; +import { existsSync, readFileSync } from 'node:fs'; +import * as path from 'node:path'; +import { describe, expect, it } from 'vitest'; +import ElkConstructor from 'elkjs/lib/elk.bundled.js'; +import type { ElkNode } from 'elkjs'; +import { DefaultElementFilter } from '@eclipse-glsp/layout-elk'; +import { layoutOnServer } from '../src/server/elk-server/elk-server-client'; +import { ElkServerProcess } from '../src/server/elk-server/elk-server-process'; +import { GraphGModelSource } from '../src/model/graph-gmodel-source'; +import { convertToGModelRoot } from '../src/server/gmodel-convert'; +import { WorkflowElkLayoutEngine } from '../src/server/elk-layout-engine'; +import { WorkflowLayoutConfigurator } from '../src/server/layout-configurator'; +import { javaMajor } from './java-version'; + +const JAR = path.resolve(__dirname, '../resources/elk-server/elk-server-0.3.0-all.jar'); +const FIXTURES = path.resolve(__dirname, 'fixtures/elk-parity'); + +/** The ELK graphs the platform's layout engine asks a layout of, for a graph document. */ +async function elkInputs(document: unknown): Promise { + const root: any = convertToGModelRoot(new GraphGModelSource().transform(document as any) as any); + const byId = new Map(); + const index = (element: any): void => { + byId.set(element.id, element); + (element.children ?? []).forEach(index); + }; + index(root); + const modelState: any = { root, index: { get: (id: string) => byId.get(id), findByClass: (id: string) => byId.get(id) } }; + const elkjs = new (ElkConstructor as any)({ algorithms: ['layered'] }); + const captured: ElkNode[] = []; + const capturing = { + ...elkjs, + layout: (graph: ElkNode, args?: unknown) => { + captured.push(JSON.parse(JSON.stringify(graph))); + return elkjs.layout(graph, args); + }, + terminateWorker: () => undefined + }; + const engine = new WorkflowElkLayoutEngine(() => capturing as any, new DefaultElementFilter(), new WorkflowLayoutConfigurator(), modelState); + await engine.layout(); + return captured; +} + +/** Element id to the numbers that place it: x, y, width and height, or an edge's route. */ +function placements(node: any, out: Record = {}): Record { + const shape = (element: any): number[] => [element.x ?? 0, element.y ?? 0, element.width ?? 0, element.height ?? 0]; + out[node.id] = shape(node); + for (const port of node.ports ?? []) { + out[port.id] = shape(port); + } + for (const label of node.labels ?? []) { + if (label.id) { + out[label.id] = shape(label); + } + } + for (const edge of node.edges ?? []) { + const section = edge.sections?.[0]; + out[edge.id] = section + ? [section.startPoint.x, section.startPoint.y, ...(section.bendPoints ?? []).flatMap((p: any) => [p.x, p.y]), section.endPoint.x, section.endPoint.y] + : []; + } + for (const child of node.children ?? []) { + placements(child, out); + } + return out; +} + +/** Rounded to a thousandth of a pixel, so the comparison reads in a diff. */ +function rounded(placed: Record): Record { + return Object.fromEntries(Object.entries(placed).map(([id, numbers]) => [id, numbers.map(n => Math.round(n * 1000) / 1000)])); +} + +describe.skipIf(javaMajor() < 17 || !existsSync(JAR))('the shipped ELK server and elkjs', () => { + it.each(['ndf-trap.json', 'hdl-glue.json'])('lay %s out alike, node for node and route for route', async fixture => { + const inputs = await elkInputs(JSON.parse(readFileSync(path.join(FIXTURES, fixture), 'utf8'))); + expect(inputs.length).toBeGreaterThan(0); + const server = new ElkServerProcess({ jar: JAR, javaOpts: '-Xmx512m' }); + try { + const port = await server.ensureStarted(); + expect(port, 'the server started').toBeDefined(); + for (const input of inputs) { + const elkjs = new (ElkConstructor as any)({ algorithms: ['layered'] }); + const byElkjs = rounded(placements(await elkjs.layout(JSON.parse(JSON.stringify(input))))); + const byServer = rounded(placements(await layoutOnServer(port!, JSON.parse(JSON.stringify(input)), 60_000))); + expect(byServer).toEqual(byElkjs); + } + } finally { + server.dispose(); + } + }, 120_000); +}); diff --git a/packages/diagram-server/test/elk-server.test.ts b/packages/diagram-server/test/elk-server.test.ts index c102387..c4cbffe 100644 --- a/packages/diagram-server/test/elk-server.test.ts +++ b/packages/diagram-server/test/elk-server.test.ts @@ -28,6 +28,7 @@ import { GraphGModelSource } from '../src/model/graph-gmodel-source'; import { convertToGModelRoot } from '../src/server/gmodel-convert'; import { WorkflowElkLayoutEngine } from '../src/server/elk-layout-engine'; import { WorkflowLayoutConfigurator } from '../src/server/layout-configurator'; +import { javaMajor } from './java-version'; const frame = (json: unknown): Buffer => { const body = gzipSync(Buffer.from(JSON.stringify(json))); @@ -253,10 +254,8 @@ describe('the ELK factory a diagram server gets', () => { }); }); -const JAR = path.resolve(__dirname, '../resources/elk-server/elk-server-0.2.0-all.jar'); -const hasJava = spawnSync('java', ['-version']).status === 0; - -describe.skipIf(!hasJava || !existsSync(JAR))('the shipped ELK server', () => { +const JAR = path.resolve(__dirname, '../resources/elk-server/elk-server-0.3.0-all.jar'); +describe.skipIf(javaMajor() < 17 || !existsSync(JAR))('the shipped ELK server', () => { it('lays out a diagram through the platform’s layout engine', async () => { // Braces in every id, which the server's plain mode would hang on. const nodes = Array.from({ length: 12 }, (_, i) => ({ diff --git a/packages/diagram-server/test/fixtures/elk-parity/hdl-glue.json b/packages/diagram-server/test/fixtures/elk-parity/hdl-glue.json new file mode 100644 index 0000000..966ab03 --- /dev/null +++ b/packages/diagram-server/test/fixtures/elk-parity/hdl-glue.json @@ -0,0 +1,627 @@ +{ + "graph": { + "edges": [ + { + "from": "port:wf:input:a:out", + "fromNode": "wf:input:a", + "id": "edge:port:wf:input:a:out->port:node:scope:root:assign:t:in:0", + "meta": { + "signal": "a" + }, + "scope": "scope:root", + "source": { + "column": 10, + "file": "/glue.sv", + "line": 19 + }, + "to": "port:node:scope:root:assign:t:in:0", + "toNode": "node:scope:root:assign:t" + }, + { + "from": "port:node:scope:root:assign:any_b:out", + "fromNode": "node:scope:root:assign:any_b", + "id": "edge:port:node:scope:root:assign:any_b:out->port:wf:output:any_b:in", + "meta": { + "signal": "any_b" + }, + "scope": "scope:root", + "source": { + "column": 24, + "file": "/glue.sv", + "line": 15 + }, + "to": "port:wf:output:any_b:in", + "toNode": "wf:output:any_b" + }, + { + "from": "port:wf:input:b:out", + "fromNode": "wf:input:b", + "id": "edge:port:wf:input:b:out->port:node:scope:root:assign:t:in:1", + "meta": { + "signal": "b" + }, + "scope": "scope:root", + "source": { + "column": 10, + "file": "/glue.sv", + "line": 19 + }, + "to": "port:node:scope:root:assign:t:in:1", + "toNode": "node:scope:root:assign:t" + }, + { + "from": "port:wf:input:b:out", + "fromNode": "wf:input:b", + "id": "edge:port:wf:input:b:out->port:node:scope:root:assign:any_b:in:0", + "meta": { + "signal": "b" + }, + "scope": "scope:root", + "source": { + "column": 10, + "file": "/glue.sv", + "line": 25 + }, + "to": "port:node:scope:root:assign:any_b:in:0", + "toNode": "node:scope:root:assign:any_b" + }, + { + "from": "port:node:scope:root:u_buf:y", + "fromNode": "node:scope:root:u_buf", + "id": "edge:port:node:scope:root:u_buf:y->port:wf:output:lo:in", + "meta": { + "signal": "lo" + }, + "scope": "scope:root", + "source": { + "column": 24, + "file": "/glue.sv", + "line": 13 + }, + "to": "port:wf:output:lo:in", + "toNode": "wf:output:lo" + }, + { + "from": "port:wf:input:sel:out", + "fromNode": "wf:input:sel", + "id": "edge:port:wf:input:sel:out->port:node:scope:root:assign:y[1:0]:in:2", + "meta": { + "signal": "sel" + }, + "scope": "scope:root", + "source": { + "column": 10, + "file": "/glue.sv", + "line": 24 + }, + "to": "port:node:scope:root:assign:y[1:0]:in:2", + "toNode": "node:scope:root:assign:y[1:0]" + }, + { + "from": "port:node:scope:root:assign:t:out", + "fromNode": "node:scope:root:assign:t", + "id": "edge:port:node:scope:root:assign:t:out->port:node:scope:root:assign:y[1:0]:in:0", + "meta": { + "signal": "t" + }, + "scope": "scope:root", + "source": { + "column": 10, + "file": "/glue.sv", + "line": 24 + }, + "to": "port:node:scope:root:assign:y[1:0]:in:0", + "toNode": "node:scope:root:assign:y[1:0]" + }, + { + "from": "port:node:scope:root:assign:t:out", + "fromNode": "node:scope:root:assign:t", + "id": "edge:port:node:scope:root:assign:t:out->port:node:scope:root:u_buf:a", + "meta": { + "signal": "t" + }, + "scope": "scope:root", + "source": { + "column": 8, + "file": "/glue.sv", + "line": 21 + }, + "to": "port:node:scope:root:u_buf:a", + "toNode": "node:scope:root:u_buf" + }, + { + "from": "port:node:scope:root:assign:t:out", + "fromNode": "node:scope:root:assign:t", + "id": "edge:port:node:scope:root:assign:t:out->port:node:scope:root:assign:y[1:0]:in:1", + "meta": { + "signal": "t" + }, + "scope": "scope:root", + "source": { + "column": 10, + "file": "/glue.sv", + "line": 24 + }, + "to": "port:node:scope:root:assign:y[1:0]:in:1", + "toNode": "node:scope:root:assign:y[1:0]" + }, + { + "from": "port:node:scope:root:assign:y[3:2]:out", + "fromNode": "node:scope:root:assign:y[3:2]", + "id": "edge:port:node:scope:root:assign:y[3:2]:out->port:wf:output:y:in", + "meta": { + "signal": "y" + }, + "scope": "scope:root", + "source": { + "column": 24, + "file": "/glue.sv", + "line": 14 + }, + "to": "port:wf:output:y:in", + "toNode": "wf:output:y" + }, + { + "from": "port:node:scope:root:assign:y[1:0]:out", + "fromNode": "node:scope:root:assign:y[1:0]", + "id": "edge:port:node:scope:root:assign:y[1:0]:out->port:wf:output:y:in", + "meta": { + "signal": "y" + }, + "scope": "scope:root", + "source": { + "column": 24, + "file": "/glue.sv", + "line": 14 + }, + "to": "port:wf:output:y:in", + "toNode": "wf:output:y" + } + ], + "id": "wf:glue", + "meta": { + "file": "/glue.sv", + "source": { + "column": 8, + "file": "/glue.sv", + "line": 8 + }, + "unitKind": "module" + }, + "nodes": [ + { + "id": "wf:input:a", + "kind": "wf-input", + "label": "a", + "meta": { + "source": { + "column": 24, + "file": "/glue.sv", + "line": 10 + }, + "width": 4 + }, + "ports": [ + { + "direction": "out", + "id": "port:wf:input:a:out", + "name": "a", + "role": "data", + "source": { + "column": 24, + "file": "/glue.sv", + "line": 10 + }, + "type": "logic[3:0]" + } + ], + "scope": "scope:root" + }, + { + "id": "wf:input:b", + "kind": "wf-input", + "label": "b", + "meta": { + "source": { + "column": 24, + "file": "/glue.sv", + "line": 11 + }, + "width": 4 + }, + "ports": [ + { + "direction": "out", + "id": "port:wf:input:b:out", + "name": "b", + "role": "data", + "source": { + "column": 24, + "file": "/glue.sv", + "line": 11 + }, + "type": "logic[3:0]" + } + ], + "scope": "scope:root" + }, + { + "id": "wf:input:sel", + "kind": "wf-input", + "label": "sel", + "meta": { + "source": { + "column": 24, + "file": "/glue.sv", + "line": 12 + }, + "width": 1 + }, + "ports": [ + { + "direction": "out", + "id": "port:wf:input:sel:out", + "name": "sel", + "role": "data", + "source": { + "column": 24, + "file": "/glue.sv", + "line": 12 + }, + "type": "logic" + } + ], + "scope": "scope:root" + }, + { + "id": "wf:output:lo", + "kind": "wf-output", + "label": "lo", + "meta": { + "source": { + "column": 24, + "file": "/glue.sv", + "line": 13 + }, + "width": 2 + }, + "ports": [ + { + "direction": "in", + "id": "port:wf:output:lo:in", + "name": "lo", + "role": "data", + "source": { + "column": 24, + "file": "/glue.sv", + "line": 13 + }, + "type": "logic[1:0]" + } + ], + "scope": "scope:root" + }, + { + "id": "wf:output:y", + "kind": "wf-output", + "label": "y", + "meta": { + "source": { + "column": 24, + "file": "/glue.sv", + "line": 14 + }, + "width": 4 + }, + "ports": [ + { + "direction": "in", + "id": "port:wf:output:y:in", + "name": "y", + "role": "data", + "source": { + "column": 24, + "file": "/glue.sv", + "line": 14 + }, + "type": "logic[3:0]" + } + ], + "scope": "scope:root" + }, + { + "id": "wf:output:any_b", + "kind": "wf-output", + "label": "any_b", + "meta": { + "source": { + "column": 24, + "file": "/glue.sv", + "line": 15 + }, + "width": 1 + }, + "ports": [ + { + "direction": "in", + "id": "port:wf:output:any_b:in", + "name": "any_b", + "role": "data", + "source": { + "column": 24, + "file": "/glue.sv", + "line": 15 + }, + "type": "logic" + } + ], + "scope": "scope:root" + }, + { + "id": "node:scope:root:u_buf", + "kind": "instance", + "label": "u_buf", + "meta": { + "definitionAnnotations": [ + { + "name": "instance" + } + ], + "referencedEntityName": "buf2", + "referencedSource": { + "column": 8, + "file": "/glue.sv", + "line": 4 + }, + "source": { + "column": 8, + "file": "/glue.sv", + "line": 21 + } + }, + "ports": [ + { + "direction": "in", + "id": "port:node:scope:root:u_buf:a", + "name": "a", + "role": "data", + "source": { + "column": 32, + "file": "/glue.sv", + "line": 4 + }, + "type": "logic[1:0]", + "width": 2 + }, + { + "direction": "out", + "id": "port:node:scope:root:u_buf:y", + "name": "y", + "role": "data", + "source": { + "column": 54, + "file": "/glue.sv", + "line": 4 + }, + "type": "logic[1:0]", + "width": 2 + } + ], + "scope": "scope:root", + "type": "buf2" + }, + { + "id": "node:scope:root:assign:t", + "kind": "logic", + "label": "a & b", + "meta": { + "assignment": "t", + "definitionAnnotations": [ + { + "name": "logic" + } + ], + "expression": "a & b", + "readOnly": true, + "source": { + "column": 10, + "file": "/glue.sv", + "line": 19 + } + }, + "ports": [ + { + "direction": "out", + "id": "port:node:scope:root:assign:t:out", + "name": "t", + "role": "data", + "type": "logic", + "width": 4 + }, + { + "direction": "in", + "id": "port:node:scope:root:assign:t:in:0", + "name": "a", + "role": "data", + "type": "logic", + "width": 4 + }, + { + "direction": "in", + "id": "port:node:scope:root:assign:t:in:1", + "name": "b", + "role": "data", + "type": "logic", + "width": 4 + } + ], + "scope": "scope:root", + "type": "logic" + }, + { + "id": "node:scope:root:assign:y[3:2]", + "kind": "logic", + "label": "2'b00", + "meta": { + "assignment": "y[3:2]", + "definitionAnnotations": [ + { + "name": "logic" + } + ], + "expression": "2'b00", + "readOnly": true, + "source": { + "column": 10, + "file": "/glue.sv", + "line": 23 + } + }, + "ports": [ + { + "direction": "out", + "id": "port:node:scope:root:assign:y[3:2]:out", + "name": "y[3:2]", + "role": "data", + "type": "logic", + "width": 2 + } + ], + "scope": "scope:root", + "type": "logic" + }, + { + "id": "node:scope:root:assign:y[1:0]", + "kind": "logic", + "label": "sel ? t[1:0] : u[3:2]", + "meta": { + "assignment": "y[1:0]", + "definitionAnnotations": [ + { + "name": "logic" + } + ], + "expression": "sel ? t[1:0] : u[3:2]", + "readOnly": true, + "source": { + "column": 10, + "file": "/glue.sv", + "line": 24 + } + }, + "ports": [ + { + "direction": "out", + "id": "port:node:scope:root:assign:y[1:0]:out", + "name": "y[1:0]", + "role": "data", + "type": "logic", + "width": 2 + }, + { + "direction": "in", + "id": "port:node:scope:root:assign:y[1:0]:in:0", + "name": "t[1:0]", + "role": "data", + "type": "logic", + "width": 2 + }, + { + "direction": "in", + "id": "port:node:scope:root:assign:y[1:0]:in:1", + "name": "u[3:2]", + "role": "data", + "type": "logic", + "width": 2 + }, + { + "direction": "in", + "id": "port:node:scope:root:assign:y[1:0]:in:2", + "name": "sel", + "role": "data", + "type": "logic", + "width": 1 + } + ], + "scope": "scope:root", + "type": "logic" + }, + { + "id": "node:scope:root:assign:any_b", + "kind": "logic", + "label": "|b", + "meta": { + "assignment": "any_b", + "definitionAnnotations": [ + { + "name": "logic" + } + ], + "expression": "|b", + "readOnly": true, + "source": { + "column": 10, + "file": "/glue.sv", + "line": 25 + } + }, + "ports": [ + { + "direction": "out", + "id": "port:node:scope:root:assign:any_b:out", + "name": "any_b", + "role": "data", + "type": "logic", + "width": 1 + }, + { + "direction": "in", + "id": "port:node:scope:root:assign:any_b:in:0", + "name": "b", + "role": "data", + "type": "logic", + "width": 4 + } + ], + "scope": "scope:root", + "type": "logic" + } + ], + "subgraphs": [ + { + "children": [], + "controlNodeId": null, + "edges": [ + "edge:port:wf:input:a:out->port:node:scope:root:assign:t:in:0", + "edge:port:node:scope:root:assign:any_b:out->port:wf:output:any_b:in", + "edge:port:wf:input:b:out->port:node:scope:root:assign:t:in:1", + "edge:port:wf:input:b:out->port:node:scope:root:assign:any_b:in:0", + "edge:port:node:scope:root:u_buf:y->port:wf:output:lo:in", + "edge:port:wf:input:sel:out->port:node:scope:root:assign:y[1:0]:in:2", + "edge:port:node:scope:root:assign:t:out->port:node:scope:root:assign:y[1:0]:in:0", + "edge:port:node:scope:root:assign:t:out->port:node:scope:root:u_buf:a", + "edge:port:node:scope:root:assign:t:out->port:node:scope:root:assign:y[1:0]:in:1", + "edge:port:node:scope:root:assign:y[3:2]:out->port:wf:output:y:in", + "edge:port:node:scope:root:assign:y[1:0]:out->port:wf:output:y:in" + ], + "id": "scope:root", + "kind": "root", + "nodes": [ + "wf:input:a", + "wf:input:b", + "wf:input:sel", + "wf:output:lo", + "wf:output:y", + "wf:output:any_b", + "node:scope:root:u_buf", + "node:scope:root:assign:t", + "node:scope:root:assign:y[3:2]", + "node:scope:root:assign:y[1:0]", + "node:scope:root:assign:any_b" + ], + "parent": null + } + ] + }, + "version": "1.1" +} diff --git a/packages/diagram-server/test/fixtures/elk-parity/ndf-trap.json b/packages/diagram-server/test/fixtures/elk-parity/ndf-trap.json new file mode 100644 index 0000000..177e235 --- /dev/null +++ b/packages/diagram-server/test/fixtures/elk-parity/ndf-trap.json @@ -0,0 +1,1426 @@ +{ + "graph": { + "edges": [ + { + "from": "port:node:scope:root:MODEL-CPU-LAUNCHTRAP:out", + "id": "edge:port:node:scope:root:MODEL-CPU-LAUNCHTRAP:out->port:node:scope:root:TRAP-ALIGN-001:in:implements", + "meta": { + "label": "implements", + "role": "implements" + }, + "scope": "scope:root", + "source": { + "file": "", + "line": 43 + }, + "to": "port:node:scope:root:TRAP-ALIGN-001:in" + }, + { + "from": "port:node:scope:root:MODEL-CPU-LDMISALIGN:out", + "id": "edge:port:node:scope:root:MODEL-CPU-LDMISALIGN:out->port:node:scope:root:TRAP-ALIGN-001:in:implements", + "meta": { + "label": "implements", + "role": "implements" + }, + "scope": "scope:root", + "source": { + "file": "", + "line": 168 + }, + "to": "port:node:scope:root:TRAP-ALIGN-001:in" + }, + { + "from": "port:node:scope:root:MODEL-CPU-LDMISALIGN:out", + "id": "edge:port:node:scope:root:MODEL-CPU-LDMISALIGN:out->port:node:scope:root:TRAP-CAUSE-001:in:implements", + "meta": { + "label": "implements", + "role": "implements" + }, + "scope": "scope:root", + "source": { + "file": "", + "line": 168 + }, + "to": "port:node:scope:root:TRAP-CAUSE-001:in" + }, + { + "from": "port:node:scope:root:MODEL-CPU-PCPIEXPIRE:out", + "id": "edge:port:node:scope:root:MODEL-CPU-PCPIEXPIRE:out->port:node:scope:root:TRAP-ILL-001:in:implements", + "meta": { + "label": "implements", + "role": "implements" + }, + "scope": "scope:root", + "source": { + "file": "", + "line": 63 + }, + "to": "port:node:scope:root:TRAP-ILL-001:in" + }, + { + "from": "port:node:scope:root:MODEL-CPU-RS2STMEM:out", + "id": "edge:port:node:scope:root:MODEL-CPU-RS2STMEM:out->port:node:scope:root:TRAP-ALIGN-001:in:implements", + "meta": { + "label": "implements", + "role": "implements" + }, + "scope": "scope:root", + "source": { + "file": "", + "line": 118 + }, + "to": "port:node:scope:root:TRAP-ALIGN-001:in" + }, + { + "from": "port:node:scope:root:MODEL-CPU-STMISALIGN:out", + "id": "edge:port:node:scope:root:MODEL-CPU-STMISALIGN:out->port:node:scope:root:TRAP-ALIGN-001:in:implements", + "meta": { + "label": "implements", + "role": "implements" + }, + "scope": "scope:root", + "source": { + "file": "", + "line": 153 + }, + "to": "port:node:scope:root:TRAP-ALIGN-001:in" + }, + { + "from": "port:node:scope:root:MODEL-CPU-STMISALIGN:out", + "id": "edge:port:node:scope:root:MODEL-CPU-STMISALIGN:out->port:node:scope:root:TRAP-CAUSE-001:in:implements", + "meta": { + "label": "implements", + "role": "implements" + }, + "scope": "scope:root", + "source": { + "file": "", + "line": 153 + }, + "to": "port:node:scope:root:TRAP-CAUSE-001:in" + }, + { + "from": "port:node:scope:root:MODEL-CPU-TOLDMEM:out", + "id": "edge:port:node:scope:root:MODEL-CPU-TOLDMEM:out->port:node:scope:root:TRAP-ALIGN-001:in:implements", + "meta": { + "label": "implements", + "role": "implements" + }, + "scope": "scope:root", + "source": { + "file": "", + "line": 83 + }, + "to": "port:node:scope:root:TRAP-ALIGN-001:in" + }, + { + "from": "port:node:scope:root:MODEL-CPU-TOOFFER:out", + "id": "edge:port:node:scope:root:MODEL-CPU-TOOFFER:out->port:node:scope:root:TRAP-ILL-001:in:implements", + "meta": { + "label": "implements", + "role": "implements" + }, + "scope": "scope:root", + "source": { + "file": "", + "line": 93 + }, + "to": "port:node:scope:root:TRAP-ILL-001:in" + }, + { + "from": "port:node:scope:root:MODEL-CPU-TOSTMEM:out", + "id": "edge:port:node:scope:root:MODEL-CPU-TOSTMEM:out->port:node:scope:root:TRAP-ALIGN-001:in:implements", + "meta": { + "label": "implements", + "role": "implements" + }, + "scope": "scope:root", + "source": { + "file": "", + "line": 78 + }, + "to": "port:node:scope:root:TRAP-ALIGN-001:in" + }, + { + "from": "port:node:scope:root:MODEL-CPU-TOTRAP:out", + "id": "edge:port:node:scope:root:MODEL-CPU-TOTRAP:out->port:node:scope:root:TRAP-ILL-001:in:implements", + "meta": { + "label": "implements", + "role": "implements" + }, + "scope": "scope:root", + "source": { + "file": "", + "line": 98 + }, + "to": "port:node:scope:root:TRAP-ILL-001:in" + }, + { + "from": "port:node:scope:root:MODEL-CPU-TRAPHALT:out", + "id": "edge:port:node:scope:root:MODEL-CPU-TRAPHALT:out->port:node:scope:root:TRAP-001:in:implements", + "meta": { + "label": "implements", + "role": "implements" + }, + "scope": "scope:root", + "source": { + "file": "", + "line": 173 + }, + "to": "port:node:scope:root:TRAP-001:in" + }, + { + "from": "port:node:scope:root:PRM-CATCH-001:out", + "id": "edge:port:node:scope:root:PRM-CATCH-001:out->port:node:scope:root:TRAP-001:in:affects", + "meta": { + "label": "affects", + "role": "affects" + }, + "scope": "scope:root", + "source": { + "file": "", + "line": 309 + }, + "to": "port:node:scope:root:TRAP-001:in" + }, + { + "from": "port:node:scope:root:Q-003:out", + "id": "edge:port:node:scope:root:Q-003:out->port:node:scope:root:TRAP-ILL-001:in:blocks", + "meta": { + "label": "blocks", + "role": "blocks" + }, + "scope": "scope:root", + "source": { + "file": "", + "line": 9 + }, + "to": "port:node:scope:root:TRAP-ILL-001:in" + }, + { + "from": "port:node:scope:root:TRAP-001:out", + "id": "edge:port:node:scope:root:TRAP-001:out->port:node:scope:root:PIPE-FSM-001:in:refines", + "meta": { + "label": "refines", + "role": "refines" + }, + "scope": "scope:root", + "source": { + "file": "", + "line": 25 + }, + "to": "port:node:scope:root:PIPE-FSM-001:in" + }, + { + "from": "port:node:scope:root:TRAP-ALIGN-001:out", + "id": "edge:port:node:scope:root:TRAP-ALIGN-001:out->port:node:scope:root:PRM-CATCH-001:in:depends-on", + "meta": { + "label": "depends-on", + "role": "depends-on" + }, + "scope": "scope:root", + "source": { + "file": "", + "line": 181 + }, + "to": "port:node:scope:root:PRM-CATCH-001:in" + }, + { + "from": "port:node:scope:root:TRAP-ALIGN-001:out", + "id": "edge:port:node:scope:root:TRAP-ALIGN-001:out->port:node:scope:root:TRAP-CAUSE-001:in:refines", + "meta": { + "label": "refines", + "role": "refines" + }, + "scope": "scope:root", + "source": { + "file": "", + "line": 181 + }, + "to": "port:node:scope:root:TRAP-CAUSE-001:in" + }, + { + "from": "port:node:scope:root:TRAP-CAUSE-001:out", + "id": "edge:port:node:scope:root:TRAP-CAUSE-001:out->port:node:scope:root:TRAP-001:in:refines", + "meta": { + "label": "refines", + "role": "refines" + }, + "scope": "scope:root", + "source": { + "file": "", + "line": 53 + }, + "to": "port:node:scope:root:TRAP-001:in" + }, + { + "from": "port:node:scope:root:TRAP-ILL-001:out", + "id": "edge:port:node:scope:root:TRAP-ILL-001:out->port:node:scope:root:TRAP-CAUSE-001:in:refines", + "meta": { + "label": "refines", + "role": "refines" + }, + "scope": "scope:root", + "source": { + "file": "", + "line": 104 + }, + "to": "port:node:scope:root:TRAP-CAUSE-001:in" + }, + { + "from": "port:node:scope:root:VER-TRAP-001:out", + "id": "edge:port:node:scope:root:VER-TRAP-001:out->port:node:scope:root:TRAP-001:in:verifies", + "meta": { + "label": "verifies", + "role": "verifies" + }, + "scope": "scope:root", + "source": { + "file": "", + "line": 216 + }, + "to": "port:node:scope:root:TRAP-001:in" + } + ], + "id": "wf:DOC-PICORV32-TRAP", + "meta": { + "title": "Traps", + "workflow": "DOC-PICORV32-TRAP" + }, + "nodes": [ + { + "id": "node:scope:root:TRAP-001", + "kind": "requirement", + "label": "A trap is a halt", + "meta": { + "badges": [ + { + "text": "MUST", + "tone": "info" + }, + { + "text": "L1", + "tone": "neutral" + } + ], + "definitionAnnotations": [ + { + "name": "requirement" + }, + { + "arguments": [ + { + "name": "id", + "value": "TRAP-001" + }, + { + "name": "modality", + "value": "must" + }, + { + "name": "refinement", + "value": "L1" + }, + { + "name": "status", + "value": "active" + }, + { + "name": "owner", + "value": "picorv32-spec" + }, + { + "name": "derived-from", + "value": "picorv32.v#cpu_state_trap" + }, + { + "name": "since", + "value": "1.0" + } + ], + "name": "ndf" + }, + { + "name": "body", + "text": "Entering `cpu_state_trap` MUST stop the machine permanently: the state is\nabsorbing, `trap` is asserted and stays asserted, and **the only exit is reset**\n([[FSM-TRAP-001]]).\n\n```text ndf:normative\nwhat stops what does not\nthe CPU state machine the two counters keep counting -- count_cycle\n is assigned unconditionally (:1433, CNT-001)\ninstruction fetch the timer keeps counting down, and can still\n raise a pending bit that nothing will service\n (:1442-1443, IRQ-TIMER-001)\nthe memory interface, once eoi holds whatever it held\nany outstanding transfer (IRQ-EOI-001)\ncompletes (MEM-FSM-001)\n```\n\nSo a trapped core is **not** a quiescent core, and a testbench that waits for\nactivity to cease will wait for ever. What it must watch is the `trap` output,\nwhich is the only signal that says the machine has stopped\n([[VER-TRAP-001]]).\n\nAn implementation MUST NOT provide a return path. There is no `mepc`, no\n`mcause`, no vector, and nothing reads `reg_pc` after the trap\n([[CHR-NG-002]])." + } + ] + }, + "ports": [ + { + "direction": "in", + "id": "port:node:scope:root:TRAP-001:in", + "name": "in" + }, + { + "direction": "out", + "id": "port:node:scope:root:TRAP-001:out", + "name": "out" + } + ], + "scope": "scope:group:L1", + "type": "TRAP-001" + }, + { + "id": "node:scope:root:TRAP-ALIGN-001", + "kind": "requirement", + "label": "The alignment checks", + "meta": { + "badges": [ + { + "text": "MUST", + "tone": "info" + }, + { + "text": "L2", + "tone": "neutral" + } + ], + "definitionAnnotations": [ + { + "name": "requirement" + }, + { + "arguments": [ + { + "name": "id", + "value": "TRAP-ALIGN-001" + }, + { + "name": "modality", + "value": "must" + }, + { + "name": "refinement", + "value": "L2" + }, + { + "name": "status", + "value": "active" + }, + { + "name": "owner", + "value": "picorv32-spec" + }, + { + "name": "derived-from", + "value": "picorv32.v#CATCH_MISALIGN" + }, + { + "name": "since", + "value": "1.0" + } + ], + "name": "ndf" + }, + { + "name": "body", + "text": "With `CATCH_MISALIGN`, the core MUST check two alignments after the state `case`\n([[FSM-POST-001]]) and escalate per [[TRAP-CAUSE-001]] on interrupt 2:\n\n```text ndf:normative\ncheck guard line\na word data access with reg_op1[1:0] != 0 CATCH_MISALIGN && 1922-1929\na halfword data access with reg_op1[0] != 0 resetn && 1930-1937\n (mem_do_rdata ||\n mem_do_wdata)\nan instruction fetch with reg_pc[0] != 0, or CATCH_MISALIGN && 1938-1944\n |reg_pc[1:0] != 0 without COMPRESSED_ISA resetn &&\n mem_do_rinst\n```\n\nFive obligations.\n\n1. **The data check reads `reg_op1`, not the presented address.** By the time a\n request flag is set, `reg_op1` holds the effective address\n ([[FSM-STMEM-001]]#1), and `mem_addr` has been word-aligned by `mem_la_addr`\n ([[MIF-LA-001]]) \u2014 so checking the bus address would find every access\n aligned.\n2. **The data check is on `mem_wordsize`, and a byte access is never\n misaligned.** There are only two arms, for word and halfword; `mem_wordsize`\n 2 has none ([[MEM-SIZE-001]]).\n3. **The instruction check narrows under `COMPRESSED_ISA`** to `reg_pc[0]`\n alone, because a 16-bit instruction may start at any even address\n ([[RVC-001]]).\n4. **The instruction check fires on `mem_do_rinst` and not on\n `mem_do_prefetch`**, so a prefetch to a misaligned address raises nothing \u2014\n it cannot, because a prefetch's address is `reg_next_pc` advanced by 2 or 4\n from an address already checked ([[PC-ADV-001]]). An implementation that\n checks the prefetch traps on a target it was never going to execute.\n5. **The data checks do NOT redirect.** Unlike the instruction cases of\n [[TRAP-ILL-001]], the escalating arms here set `next_irq_pending` and leave\n `cpu_state` alone (`picorv32.v:1925-1927`, `:1932-1934`) \u2014 so the misaligned\n access **proceeds** and the interrupt is taken when the instruction finishes.\n Only the halting arm assigns `cpu_state`. That asymmetry is in the RTL and an\n implementation that redirects here would skip the access.\n\nWith `CATCH_MISALIGN` off, neither check exists and the low address bits are\n**forced to zero** on `reg_pc` and `reg_next_pc` every cycle instead\n(`picorv32.v:1965-1972`, [[PC-ADV-001]]). A misaligned **data** access is then\nnot truncated at all \u2014 `reg_op1` is untouched \u2014 so the access proceeds with the\nlow bits as the byte-lane selector they already were\n([[MEM-SIZE-001]]), and a word load from an odd address returns the word\ncontaining it. That is silently wrong rather than detected, which is what\n[[PRM-CATCH-001]] records.\n\n**WHERE THE HALT IS REPORTED, MEASURED.** The instruction check fires in the cycle\n*after* `reg_pc` becomes the misaligned address, because `reg_pc` is assigned\nnon-blockingly in `cpu_state_fetch` while `mem_do_rinst` rises with it\n(`picorv32.v:1938`). By then no instruction at the new address has been decoded, so\n`dbg_insn_addr` still holds the **previous** one \u2014 and the trap is therefore\nreported as that instruction's retirement, carrying whatever register write it\nperformed (`picorv32.v:2014-2023`, [[VER-RVFI-001]]). There is **one** event, not\ntwo: the retirement and the halt coincide.\n\nThat was established by running the module rather than by reading it\n([[D-0004]]#1). `gen/tests/rtl.py` compiles `picorv32.v` with Verilator, runs it on\nthe same images the model runs, and compares its RVFI stream against\n`gen/harness/refmodel.py`; the model and the reference both emitted two events\nuntil 2026-09-09, and no real run produces that.\n\nThe data checks differ, and the difference is obligation 5's: the access is already\nrequested when the check fires, so the halt is reported on the instruction that\nmade it, at its own address." + } + ] + }, + "ports": [ + { + "direction": "in", + "id": "port:node:scope:root:TRAP-ALIGN-001:in", + "name": "in" + }, + { + "direction": "out", + "id": "port:node:scope:root:TRAP-ALIGN-001:out", + "name": "out" + } + ], + "scope": "scope:group:L2", + "type": "TRAP-ALIGN-001" + }, + { + "id": "node:scope:root:TRAP-CAUSE-001", + "kind": "requirement", + "label": "Three conditions, each with two outcomes", + "meta": { + "badges": [ + { + "text": "MUST", + "tone": "info" + }, + { + "text": "L1", + "tone": "neutral" + } + ], + "definitionAnnotations": [ + { + "name": "requirement" + }, + { + "arguments": [ + { + "name": "id", + "value": "TRAP-CAUSE-001" + }, + { + "name": "modality", + "value": "must" + }, + { + "name": "refinement", + "value": "L1" + }, + { + "name": "status", + "value": "active" + }, + { + "name": "owner", + "value": "picorv32-spec" + }, + { + "name": "since", + "value": "1.0" + } + ], + "name": "ndf" + }, + { + "name": "body", + "text": "Exactly three conditions MUST be able to reach `cpu_state_trap`, and each MUST\nprefer raising an **interrupt** over halting when three things hold: interrupts\nare enabled, the corresponding mask bit is clear, and no interrupt routine is\nalready running.\n\n```text ndf:normative\ncondition internal raises halts instead when\n interrupt clause\nan undecoded instruction 1 (ebreak) TRAP- !ENABLE_IRQ, or\n (DEF-INSTRTRAP-001), an ILL-001 irq_mask[1], or\n ECALL, or an EBREAK irq_active\na misaligned data access 2 (buserror) TRAP- the same, with bit 2\n ALIGN-001\na misaligned instruction fetch 2 (buserror) TRAP- the same\n ALIGN-001\n```\n\nThe escalation test is written out identically in **six** places \u2014 three for\ninterrupt 1 and three for interrupt 2 (`picorv32.v:1608`, `:1619`, `:1780`,\n`:1925`, `:1932`, `:1940`):\n\n```text ndf:normative\nif (ENABLE_IRQ && !irq_mask[irq_N] && !irq_active)\n next_irq_pending[irq_N] = 1; -- a BLOCKING assignment\n and, for the instruction cases, cpu_state <= cpu_state_fetch\nelse\n cpu_state <= cpu_state_trap;\n```\n\nThree obligations.\n\n1. **`irq_mask` resets to all ones** ([[ARCH-RESET-001]], [[IRQ-MASK-001]]), so\n **a core that has not executed `maskirq` halts** on any of the three. The\n README says so: \"an illegal instruction or bus error while the illegal\n instruction or bus error interrupt is disabled will cause the processor to\n halt.\"\n2. **`irq_active` blocks the escalation**, so a fault *inside* the interrupt\n routine halts rather than re-entering it. There is no nesting and no double\n fault ([[IRQ-ACTIVE-001]]).\n3. **`MASKED_IRQ` can make the halt unconditional.** A bit set in that parameter\n is OR-ed into every `maskirq` write (`picorv32.v:1682`), so software cannot\n clear it and the condition halts for ever\n ([[PRM-IRQMASKS-001]], [[CON-BASIC-001]]#11).\n\n---" + } + ] + }, + "ports": [ + { + "direction": "in", + "id": "port:node:scope:root:TRAP-CAUSE-001:in", + "name": "in" + }, + { + "direction": "out", + "id": "port:node:scope:root:TRAP-CAUSE-001:out", + "name": "out" + } + ], + "scope": "scope:group:L1", + "type": "TRAP-CAUSE-001" + }, + { + "id": "node:scope:root:TRAP-ILL-001", + "kind": "requirement", + "label": "The illegal-instruction path", + "meta": { + "badges": [ + { + "text": "MUST", + "tone": "info" + }, + { + "text": "L2", + "tone": "neutral" + } + ], + "definitionAnnotations": [ + { + "name": "requirement" + }, + { + "arguments": [ + { + "name": "id", + "value": "TRAP-ILL-001" + }, + { + "name": "modality", + "value": "must" + }, + { + "name": "refinement", + "value": "L2" + }, + { + "name": "status", + "value": "active" + }, + { + "name": "owner", + "value": "picorv32-spec" + }, + { + "name": "derived-from", + "value": "picorv32.v#instr_trap" + }, + { + "name": "since", + "value": "1.0" + } + ], + "name": "ndf" + }, + { + "name": "body", + "text": "An undecoded instruction, an `ECALL` and an `EBREAK` MUST reach the **same**\nescalation, and MUST reach it through the co-processor interface\n([[PCPI-001]], [[DEC-TRAP-001]]):\n\n```text ndf:normative\nstep line\n1 instr_trap fires -- the NOR of 48 of the 49 decode flags, 679-685\n gated by CATCH_ILLINSN || WITH_PCPI. An ECALL sets it too,\n because instr_ecall_ebreak is the flag left OUT of the NOR\n2 cpu_state_ld_rs1's first arm offers the instruction: pcpi_valid 1585-1625\n rises with pcpi_insn, pcpi_rs1 and pcpi_rs2 (PCPI-001).\n At one read port the offer is made in ld_rs2 instead 1768-1786\n3 if pcpi_int_ready: take the result, drop pcpi_valid, go to 1598-1603\n cpu_state_fetch -- NOT a trap at all\n4 else if CATCH_ILLINSN && (pcpi_timeout || instr_ecall_ebreak): 1605-1613\n escalate per TRAP-CAUSE-001 on interrupt 1\n```\n\nFour obligations.\n\n1. **`instr_ecall_ebreak` short-circuits the timeout.** Step 4's disjunction\n means a system instruction escalates in the **first** cycle of\n `cpu_state_ld_rs1`, while an unknown instruction waits for `pcpi_timeout`\n ([[PCPI-TIMEOUT-001]]). So `ECALL` costs four cycles and an unclaimed\n instruction costs twenty ([[PIPE-CPI-001]]). The offer of step 2 is never\n SEEN for a system instruction: the escalation's `pcpi_valid <= 0` overrides\n the offer's `<= 1` in that same cycle ([[DEC-TRAP-001]]#3, measured\n 2026-09-24).\n2. **The escalation raises interrupt 1, not a cause code.** `irq_ebreak` is the\n localparam `1` ([[CON-WIDTH-001]]), and the interrupt routine learns which of\n the three conditions fired only from the **bitmask** in `q1`\n ([[IRQ-ENTRY-001]]) \u2014 which cannot distinguish an illegal instruction from an\n `ECALL` at all. That is a real loss of information relative to `mcause` and it\n is the machine's ([[D-0002]]).\n3. **With `WITH_PCPI` and no `CATCH_ILLINSN`, the path HANGS.** Step 1 still\n fires, step 2 still offers, and step 4 is `CATCH_ILLINSN`-guarded \u2014 so\n nothing escalates and nothing advances. `pcpi_timeout_counter` is not even\n counted, its own guard being `WITH_PCPI && CATCH_ILLINSN`\n (`picorv32.v:1423`). The core stays in `cpu_state_ld_rs1` with `pcpi_valid`\n asserted and `trap` **low**, which is the one failure a testbench watching\n `trap` cannot see. MEASURED, 2026-09-09: at `CATCH_ILLINSN = 0` with\n `ENABLE_MUL` the machine retires the instructions before the undecoded one,\n emits no trapped event and reaches the harness's cycle bound\n ([[Q-003]], `gen/tests/questions.py`). Recorded also as\n [[CON-BASIC-001]]#12, which is why `gen/params.py` refuses to elaborate the\n combination at all.\n4. **With neither `CATCH_ILLINSN` nor `WITH_PCPI`, `instr_trap` is constant\n zero** and an undecoded instruction is **executed** \u2014 it falls to\n `cpu_state_ld_rs1`'s default arm, reaches `cpu_state_exec`, and writes X to\n its destination register ([[DEC-TRAP-001]], [[ALU-OUT-001]]). It does not\n trap, interrupt or hang.\n\n`ECALL` and `EBREAK` have a **second** path that exists only at\n`!CATCH_ILLINSN` (`picorv32.v:1945-1947`):\n\n```text ndf:normative\nif (!CATCH_ILLINSN && decoder_trigger_q && !decoder_pseudo_trigger_q\n && instr_ecall_ebreak)\n cpu_state <= cpu_state_trap;\n```\n\nThis is an **unconditional halt**: it does not consult `irq_mask` and does not\nraise an interrupt, which is what the README means by \"with this option set to\n0, an `EBREAK` will trap the processor without triggering an interrupt\". It sits\nafter the state `case` ([[FSM-POST-001]]) and therefore overrides whatever state\nthe `case` chose.\n\nIts guard uses `decoder_trigger_q` and `decoder_pseudo_trigger_q` \u2014 the\n**delayed** copies (`picorv32.v:1447`, `:1449`) \u2014 rather than the live signals,\nbecause `instr_ecall_ebreak` is a phase-2 result and is not valid until the cycle\nafter `decoder_trigger` ([[DEC-PHASE-001]]). An implementation that tests the\nlive signals misses the halt by one cycle and then traps on the *following*\ninstruction." + } + ] + }, + "ports": [ + { + "direction": "in", + "id": "port:node:scope:root:TRAP-ILL-001:in", + "name": "in" + }, + { + "direction": "out", + "id": "port:node:scope:root:TRAP-ILL-001:out", + "name": "out" + } + ], + "scope": "scope:group:L2", + "type": "TRAP-ILL-001" + }, + { + "id": "node:scope:root:MODEL-CPU-LAUNCHTRAP", + "kind": "source", + "label": "`core/cpu.py::Cpu.launch_trap`", + "meta": { + "badges": [], + "definitionAnnotations": [ + { + "name": "source" + }, + { + "arguments": [ + { + "name": "id", + "value": "MODEL-CPU-LAUNCHTRAP" + }, + { + "name": "status", + "value": "active" + }, + { + "name": "owner", + "value": "picorv32-spec" + } + ], + "name": "ndf" + }, + { + "name": "body", + "text": "TRAP-ALIGN-001: the instruction about to launch is misaligned: ONE event, the previous instruction's, marked trapped; then halt." + } + ], + "external": true, + "instanceName": "DOC-PICORV32-MODEL", + "networkInstance": true, + "referencedEntityName": "DOC-PICORV32-MODEL" + }, + "ports": [ + { + "direction": "in", + "id": "port:node:scope:root:MODEL-CPU-LAUNCHTRAP:in", + "name": "in" + }, + { + "direction": "out", + "id": "port:node:scope:root:MODEL-CPU-LAUNCHTRAP:out", + "name": "out" + } + ], + "scope": "scope:root", + "type": "MODEL-CPU-LAUNCHTRAP" + }, + { + "id": "node:scope:root:MODEL-CPU-LDMISALIGN", + "kind": "source", + "label": "`core/cpu.py::Cpu.ld_misalign`", + "meta": { + "badges": [], + "definitionAnnotations": [ + { + "name": "source" + }, + { + "arguments": [ + { + "name": "id", + "value": "MODEL-CPU-LDMISALIGN" + }, + { + "name": "status", + "value": "active" + }, + { + "name": "owner", + "value": "picorv32-spec" + } + ], + "name": "ndf" + }, + { + "name": "body", + "text": "(no docstring)" + } + ], + "external": true, + "instanceName": "DOC-PICORV32-MODEL", + "networkInstance": true, + "referencedEntityName": "DOC-PICORV32-MODEL" + }, + "ports": [ + { + "direction": "in", + "id": "port:node:scope:root:MODEL-CPU-LDMISALIGN:in", + "name": "in" + }, + { + "direction": "out", + "id": "port:node:scope:root:MODEL-CPU-LDMISALIGN:out", + "name": "out" + } + ], + "scope": "scope:root", + "type": "MODEL-CPU-LDMISALIGN" + }, + { + "id": "node:scope:root:MODEL-CPU-PCPIEXPIRE", + "kind": "source", + "label": "`core/cpu.py::Cpu.pcpi_expire`", + "meta": { + "badges": [], + "definitionAnnotations": [ + { + "name": "source" + }, + { + "arguments": [ + { + "name": "id", + "value": "MODEL-CPU-PCPIEXPIRE" + }, + { + "name": "status", + "value": "active" + }, + { + "name": "owner", + "value": "picorv32-spec" + } + ], + "name": "ndf" + }, + { + "name": "body", + "text": "PCPI-001 step 4: the offer expired; escalate to the trap." + } + ], + "external": true, + "instanceName": "DOC-PICORV32-MODEL", + "networkInstance": true, + "referencedEntityName": "DOC-PICORV32-MODEL" + }, + "ports": [ + { + "direction": "in", + "id": "port:node:scope:root:MODEL-CPU-PCPIEXPIRE:in", + "name": "in" + }, + { + "direction": "out", + "id": "port:node:scope:root:MODEL-CPU-PCPIEXPIRE:out", + "name": "out" + } + ], + "scope": "scope:root", + "type": "MODEL-CPU-PCPIEXPIRE" + }, + { + "id": "node:scope:root:MODEL-CPU-RS2STMEM", + "kind": "source", + "label": "`core/cpu.py::Cpu.rs2_stmem`", + "meta": { + "badges": [], + "definitionAnnotations": [ + { + "name": "source" + }, + { + "arguments": [ + { + "name": "id", + "value": "MODEL-CPU-RS2STMEM" + }, + { + "name": "status", + "value": "active" + }, + { + "name": "owner", + "value": "picorv32-spec" + } + ], + "name": "ndf" + }, + { + "name": "body", + "text": "(no docstring)" + } + ], + "external": true, + "instanceName": "DOC-PICORV32-MODEL", + "networkInstance": true, + "referencedEntityName": "DOC-PICORV32-MODEL" + }, + "ports": [ + { + "direction": "in", + "id": "port:node:scope:root:MODEL-CPU-RS2STMEM:in", + "name": "in" + }, + { + "direction": "out", + "id": "port:node:scope:root:MODEL-CPU-RS2STMEM:out", + "name": "out" + } + ], + "scope": "scope:root", + "type": "MODEL-CPU-RS2STMEM" + }, + { + "id": "node:scope:root:MODEL-CPU-STMISALIGN", + "kind": "source", + "label": "`core/cpu.py::Cpu.st_misalign`", + "meta": { + "badges": [], + "definitionAnnotations": [ + { + "name": "source" + }, + { + "arguments": [ + { + "name": "id", + "value": "MODEL-CPU-STMISALIGN" + }, + { + "name": "status", + "value": "active" + }, + { + "name": "owner", + "value": "picorv32-spec" + } + ], + "name": "ndf" + }, + { + "name": "body", + "text": "TRAP-ALIGN-001: the access is ISSUED (the RTL's check is after the case) and the machine halts." + } + ], + "external": true, + "instanceName": "DOC-PICORV32-MODEL", + "networkInstance": true, + "referencedEntityName": "DOC-PICORV32-MODEL" + }, + "ports": [ + { + "direction": "in", + "id": "port:node:scope:root:MODEL-CPU-STMISALIGN:in", + "name": "in" + }, + { + "direction": "out", + "id": "port:node:scope:root:MODEL-CPU-STMISALIGN:out", + "name": "out" + } + ], + "scope": "scope:root", + "type": "MODEL-CPU-STMISALIGN" + }, + { + "id": "node:scope:root:MODEL-CPU-TOLDMEM", + "kind": "source", + "label": "`core/cpu.py::Cpu.to_ldmem`", + "meta": { + "badges": [], + "definitionAnnotations": [ + { + "name": "source" + }, + { + "arguments": [ + { + "name": "id", + "value": "MODEL-CPU-TOLDMEM" + }, + { + "name": "status", + "value": "active" + }, + { + "name": "owner", + "value": "picorv32-spec" + } + ], + "name": "ndf" + }, + { + "name": "body", + "text": "FSM-LDRS1-001 arm 9: the conversion is issued BEFORE the load (MEM-PREFETCH-001#2's invariant); the address and its alignment here." + } + ], + "external": true, + "instanceName": "DOC-PICORV32-MODEL", + "networkInstance": true, + "referencedEntityName": "DOC-PICORV32-MODEL" + }, + "ports": [ + { + "direction": "in", + "id": "port:node:scope:root:MODEL-CPU-TOLDMEM:in", + "name": "in" + }, + { + "direction": "out", + "id": "port:node:scope:root:MODEL-CPU-TOLDMEM:out", + "name": "out" + } + ], + "scope": "scope:root", + "type": "MODEL-CPU-TOLDMEM" + }, + { + "id": "node:scope:root:MODEL-CPU-TOOFFER", + "kind": "source", + "label": "`core/cpu.py::Cpu.to_offer`", + "meta": { + "badges": [], + "definitionAnnotations": [ + { + "name": "source" + }, + { + "arguments": [ + { + "name": "id", + "value": "MODEL-CPU-TOOFFER" + }, + { + "name": "status", + "value": "active" + }, + { + "name": "owner", + "value": "picorv32-spec" + } + ], + "name": "ndf" + }, + { + "name": "body", + "text": "PCPI-001 step 2: an undecoded instruction is offered, to every core (MD-MUX-001#1's broadcast is one port a core), with the WORD." + } + ], + "external": true, + "instanceName": "DOC-PICORV32-MODEL", + "networkInstance": true, + "referencedEntityName": "DOC-PICORV32-MODEL" + }, + "ports": [ + { + "direction": "in", + "id": "port:node:scope:root:MODEL-CPU-TOOFFER:in", + "name": "in" + }, + { + "direction": "out", + "id": "port:node:scope:root:MODEL-CPU-TOOFFER:out", + "name": "out" + } + ], + "scope": "scope:root", + "type": "MODEL-CPU-TOOFFER" + }, + { + "id": "node:scope:root:MODEL-CPU-TOSTMEM", + "kind": "source", + "label": "`core/cpu.py::Cpu.to_stmem`", + "meta": { + "badges": [], + "definitionAnnotations": [ + { + "name": "source" + }, + { + "arguments": [ + { + "name": "id", + "value": "MODEL-CPU-TOSTMEM" + }, + { + "name": "status", + "value": "active" + }, + { + "name": "owner", + "value": "picorv32-spec" + } + ], + "name": "ndf" + }, + { + "name": "body", + "text": "FSM-LDRS1-001 arm 12. The effective address (FSM-STMEM-001#1) and its alignment are computed HERE, so the stmem guard reads one bit." + } + ], + "external": true, + "instanceName": "DOC-PICORV32-MODEL", + "networkInstance": true, + "referencedEntityName": "DOC-PICORV32-MODEL" + }, + "ports": [ + { + "direction": "in", + "id": "port:node:scope:root:MODEL-CPU-TOSTMEM:in", + "name": "in" + }, + { + "direction": "out", + "id": "port:node:scope:root:MODEL-CPU-TOSTMEM:out", + "name": "out" + } + ], + "scope": "scope:root", + "type": "MODEL-CPU-TOSTMEM" + }, + { + "id": "node:scope:root:MODEL-CPU-TOTRAP", + "kind": "source", + "label": "`core/cpu.py::Cpu.to_trap`", + "meta": { + "badges": [], + "definitionAnnotations": [ + { + "name": "source" + }, + { + "arguments": [ + { + "name": "id", + "value": "MODEL-CPU-TOTRAP" + }, + { + "name": "status", + "value": "active" + }, + { + "name": "owner", + "value": "picorv32-spec" + } + ], + "name": "ndf" + }, + { + "name": "body", + "text": "FSM-LDRS1-001 arm 1: straight to the trap -- no co-processor, or an ECALL/EBREAK, which the RTL escalates in the first ld_rs1 cycle (picorv32.v:1605) before any offer is seen." + } + ], + "external": true, + "instanceName": "DOC-PICORV32-MODEL", + "networkInstance": true, + "referencedEntityName": "DOC-PICORV32-MODEL" + }, + "ports": [ + { + "direction": "in", + "id": "port:node:scope:root:MODEL-CPU-TOTRAP:in", + "name": "in" + }, + { + "direction": "out", + "id": "port:node:scope:root:MODEL-CPU-TOTRAP:out", + "name": "out" + } + ], + "scope": "scope:root", + "type": "MODEL-CPU-TOTRAP" + }, + { + "id": "node:scope:root:MODEL-CPU-TRAPHALT", + "kind": "source", + "label": "`core/cpu.py::Cpu.trap_halt`", + "meta": { + "badges": [], + "definitionAnnotations": [ + { + "name": "source" + }, + { + "arguments": [ + { + "name": "id", + "value": "MODEL-CPU-TRAPHALT" + }, + { + "name": "status", + "value": "active" + }, + { + "name": "owner", + "value": "picorv32-spec" + } + ], + "name": "ndf" + }, + { + "name": "body", + "text": "TRAP-001: absorbing; one event says it happened." + } + ], + "external": true, + "instanceName": "DOC-PICORV32-MODEL", + "networkInstance": true, + "referencedEntityName": "DOC-PICORV32-MODEL" + }, + "ports": [ + { + "direction": "in", + "id": "port:node:scope:root:MODEL-CPU-TRAPHALT:in", + "name": "in" + }, + { + "direction": "out", + "id": "port:node:scope:root:MODEL-CPU-TRAPHALT:out", + "name": "out" + } + ], + "scope": "scope:root", + "type": "MODEL-CPU-TRAPHALT" + }, + { + "id": "node:scope:root:PIPE-FSM-001", + "kind": "requirement", + "label": "The eight CPU states and their transitions", + "meta": { + "badges": [ + { + "text": "MUST", + "tone": "info" + }, + { + "text": "L2", + "tone": "neutral" + } + ], + "definitionAnnotations": [ + { + "name": "requirement" + }, + { + "arguments": [ + { + "name": "id", + "value": "PIPE-FSM-001" + }, + { + "name": "modality", + "value": "must" + }, + { + "name": "refinement", + "value": "L2" + }, + { + "name": "status", + "value": "active" + }, + { + "name": "owner", + "value": "picorv32-spec" + }, + { + "name": "derived-from", + "value": "picorv32.v#picorv32" + }, + { + "name": "since", + "value": "1.0" + } + ], + "name": "ndf" + }, + { + "name": "body", + "text": "The core MUST implement a state register `cpu_state` holding exactly these\neight states, **one-hot in eight bits** (`picorv32.v:1172-1181`):\n\n```text ndf:normative\nstate encoding what occupies it\ncpu_state_trap 8'b10000000 the machine has stopped (TRAP-001)\ncpu_state_fetch 8'b01000000 retire the previous instruction, advance the\n PC, launch the next (FSM-FETCH-001)\ncpu_state_ld_rs1 8'b00100000 read the first source, and DISPATCH on the\n decoded instruction (FSM-LDRS1-001)\ncpu_state_ld_rs2 8'b00010000 read the second source, single-port register\n file only (FSM-LDRS2-001)\ncpu_state_exec 8'b00001000 take the ALU result, resolve control flow\n (FSM-EXEC-001)\ncpu_state_shift 8'b00000100 one iteration of a multi-cycle shift\n (FSM-SHIFT-001)\ncpu_state_stmem 8'b00000010 issue and complete a store (FSM-STMEM-001)\ncpu_state_ldmem 8'b00000001 issue and complete a load (FSM-LDMEM-001)\n```\n\nThe one-hot encoding is **not** an implementation detail to be optimized away:\n`dbg_ascii_state` and the formal validity check both compare `cpu_state`\nagainst the whole eight-bit constant (`picorv32.v:2126-2134`), and a\nthree-bit-index implementation would permit a ninth value the formal property\nexists to exclude ([[VER-ASSERT-001]]).\n\nThe core MUST reset to `cpu_state_fetch` ([[ARCH-RESET-001]]) and MUST then\nmake exactly these transitions:\n\n```text ndf:normative\nfrom to when line\n(reset) fetch always 1483\nfetch fetch no fresh decode yet; or an interrupt entry\n pass; or `waitirq`; or a `jal`, whose\n target needs no operand 1491-1577\nfetch ld_rs1 a fresh decode, and the instruction is\n not a `jal` 1572-1575\nld_rs1 ld_rs1 an undecoded instruction is being offered\n to the co-processor and it is not ready 1585-1625\nld_rs1 ld_rs2 the register file is single-port 1754\nld_rs1 exec LUI / AUIPC / JAL link, JALR, an\n immediate ALU form, or a shift with\n BARREL_SHIFTER 1641-1723\nld_rs1 shift a shift-immediate without BARREL_SHIFTER 1704-1711\nld_rs1 stmem a store, dual-port 1737-1740\nld_rs1 ldmem a load 1696-1703\nld_rs1 fetch a counter read, a q-register instruction,\n `retirq`, `maskirq`, `timer`, or a\n co-processor result 1626-1695\nld_rs1 trap an undecoded instruction with nothing to\n claim it and no interrupt to take 1585-1624\nld_rs2 ld_rs2 as ld_rs1 -> ld_rs1 1768-1786\nld_rs2 stmem a store 1787-1790\nld_rs2 shift a register shift without BARREL_SHIFTER 1791-1793\nld_rs2 exec anything else 1794-1801\nld_rs2 fetch a co-processor result 1768-1786\nld_rs2 trap as ld_rs1 -> trap 1768-1786\nexec exec TWO_CYCLE_ALU or TWO_CYCLE_COMPARE is\n waiting (1807-1810); or a taken branch\n whose re-fetch has not completed 1811-1820\nexec fetch otherwise 1805-1827\nshift shift the shift count has not reached zero 1835-1851\nshift fetch the shift count has reached zero 1831-1834\nstmem stmem the transfer has not completed 1854-1878\nstmem fetch the transfer has completed 1872-1876\nldmem ldmem the transfer has not completed 1880-1926\nldmem fetch the transfer has completed 1900-1910\ntrap trap always -- the state is ABSORBING 1487-1489\n```\n\nThree properties of that table are normative and are easy to lose:\n\n1. **`cpu_state_ld_rs1` reaches all eight states**, itself included. It is the\n machine's dispatch point: the decoded instruction selects the state, and the\n selection is a `case (1'b1)` over fourteen arms in a fixed order\n ([[FSM-LDRS1-001]]). No other state has more than three successors.\n2. **`cpu_state_fetch` is reachable from every state and `cpu_state_trap` is\n reachable from all but one.** Every instruction ends by returning to\n `fetch`, which is what makes `fetch` and not a separate writeback stage the\n place the architectural write happens ([[REG-WRITE-001]]).\n3. **A transition into `cpu_state_trap` MAY come from outside the table.**\n Three blocking assignments after the `case` statement override whatever the\n occupied state decided: a misaligned data access (`picorv32.v:1922-1937`), a\n misaligned instruction fetch (`:1938-1944`), and an `ECALL`/`EBREAK` when\n `CATCH_ILLINSN` is off (`:1945-1947`). They are specified by\n [[TRAP-ALIGN-001]] and [[TRAP-ILL-001]] and they MUST take precedence,\n because a blocking assignment in the same `always` block is what the RTL\n uses to express exactly that.\n\n\u27e8COMB: `ld_rs1`'s dispatch reads the `instr_*` and `is_*` flags, `cpuregs_rs1`\nand `cpuregs_rs2` in the same cycle it assigns `cpu_state`; the register file\nread is combinational from `decoded_rs1` ([[REG-READ-001]])\u27e9" + } + ], + "external": true, + "instanceName": "DOC-PICORV32-ARCH-PIPELINE", + "networkInstance": true, + "referencedEntityName": "DOC-PICORV32-ARCH-PIPELINE" + }, + "ports": [ + { + "direction": "in", + "id": "port:node:scope:root:PIPE-FSM-001:in", + "name": "in" + }, + { + "direction": "out", + "id": "port:node:scope:root:PIPE-FSM-001:out", + "name": "out" + } + ], + "scope": "scope:root", + "type": "PIPE-FSM-001" + }, + { + "id": "node:scope:root:PRM-CATCH-001", + "kind": "option", + "label": "`CATCH_MISALIGN` and `CATCH_ILLINSN`", + "meta": { + "badges": [ + { + "text": "MUST", + "tone": "info" + }, + { + "text": "L2", + "tone": "neutral" + } + ], + "definitionAnnotations": [ + { + "name": "option" + }, + { + "arguments": [ + { + "name": "id", + "value": "PRM-CATCH-001" + }, + { + "name": "modality", + "value": "must" + }, + { + "name": "refinement", + "value": "L2" + }, + { + "name": "status", + "value": "active" + }, + { + "name": "owner", + "value": "picorv32-spec" + }, + { + "name": "since", + "value": "1.0" + } + ], + "name": "ndf" + }, + { + "name": "body", + "text": "Both default 1. They select whether the two trap-detection mechanisms exist at\nall, and **switching one off does not make the condition harmless** \u2014 it makes\nit undetected, which is the distinction an implementation MUST preserve.\n\n```text ndf:normative\nparameter at 1 at 0\nCATCH_MISALIGN a misaligned data access or no check. The low address bits\n instruction fetch raises are FORCED to zero instead, on\n IRQ 2 or halts reg_pc and reg_next_pc\n (TRAP-ALIGN-001) (picorv32.v:1962-1969), and a\n misaligned DATA access simply\n uses the truncated address\nCATCH_ILLINSN an undecoded instruction, an no illegal-instruction check\n ECALL or an EBREAK raises and no co-processor timeout.\n IRQ 1 or halts after the An ECALL or EBREAK still HALTS,\n co-processor timeout unconditionally and without\n (TRAP-ILL-001) raising an interrupt\n (picorv32.v:1945-1947)\n```\n\n`CATCH_ILLINSN` at 0 is the subtler of the two and the README says so: an\n`EBREAK` still traps, it simply stops raising IRQ 1 first. The mechanism is a\nseparate `if` after the state `case`, reached only when `!CATCH_ILLINSN`\n([[PIPE-FSM-001]]), so the two paths to `cpu_state_trap` are different code and\nonly one of them can ever be present.\n\n`instr_trap` itself is `(CATCH_ILLINSN || WITH_PCPI) && !{\u2026}`\n(`picorv32.v:679`), so an undecoded instruction is still **detected** when\n`CATCH_ILLINSN` is 0 as long as some co-processor is present \u2014 it is offered to\nthe interface and, if unclaimed, the core hangs in `cpu_state_ld_rs1` with\n`pcpi_valid` asserted, because the timeout that would have rescued it is\n`CATCH_ILLINSN`-guarded (`picorv32.v:1605`, `:1777`). Recorded as an obligation of\n[[TRAP-ILL-001]] and tracked by [[Q-003]].\n\n---" + } + ], + "external": true, + "instanceName": "DOC-PICORV32-PARAMETERS", + "networkInstance": true, + "referencedEntityName": "DOC-PICORV32-PARAMETERS" + }, + "ports": [ + { + "direction": "in", + "id": "port:node:scope:root:PRM-CATCH-001:in", + "name": "in" + }, + { + "direction": "out", + "id": "port:node:scope:root:PRM-CATCH-001:out", + "name": "out" + } + ], + "scope": "scope:root", + "type": "PRM-CATCH-001" + }, + { + "id": "node:scope:root:Q-003", + "kind": "question", + "label": "Q-003: an undecoded instruction hangs when `CATCH_ILLINSN` is off and a co-processor is present", + "meta": { + "badges": [ + { + "text": "TBD", + "tone": "warning" + }, + { + "text": "L1", + "tone": "neutral" + }, + { + "text": "resolved", + "tone": "neutral" + } + ], + "definitionAnnotations": [ + { + "name": "question" + }, + { + "arguments": [ + { + "name": "id", + "value": "Q-003" + }, + { + "name": "modality", + "value": "tbd" + }, + { + "name": "refinement", + "value": "L1" + }, + { + "name": "status", + "value": "resolved" + }, + { + "name": "owner", + "value": "picorv32-spec" + }, + { + "name": "author", + "value": "endri-bezati" + }, + { + "name": "date", + "value": "2026-09-09" + } + ], + "name": "ndf" + }, + { + "name": "body", + "text": "`instr_trap` \u2014 the NOR of the whole decoded instruction set\n([[DEF-INSTRTRAP-001]]) \u2014 is gated on `CATCH_ILLINSN || WITH_PCPI`\n(`picorv32.v:679`). The sixteen-cycle timeout that rescues an instruction no\nco-processor claims is gated on `CATCH_ILLINSN` alone\n(`picorv32.v:1605`, `:1777`).\n\nSo at `CATCH_ILLINSN = 0` with any of `ENABLE_PCPI`, `ENABLE_MUL`,\n`ENABLE_FAST_MUL` or `ENABLE_DIV` set:\n\n1. An undecoded instruction still sets `instr_trap`, because `WITH_PCPI` is\n true.\n2. `cpu_state_ld_rs1` still raises `pcpi_valid` and offers it\n (`picorv32.v:1586-1624`).\n3. If nothing asserts `pcpi_ready` \u2014 which is every instruction the attached\n core does not implement \u2014 there is no timeout, no trap and no transition.\n The core **stays in `cpu_state_ld_rs1` with `pcpi_valid` asserted, for\n ever**.\n\n`pcpi_timeout_counter` is still counted down, because its own guard is\n`WITH_PCPI && CATCH_ILLINSN` (`picorv32.v:1423`) \u2014 so at\n`CATCH_ILLINSN = 0` it is not counted at all, and `pcpi_timeout` is never set.\nThe machine does not trap, does not interrupt and does not halt: it hangs with\n`trap` low, which is the one failure mode a testbench watching `trap` cannot\nsee.\n\n**Tested, 2026-09-09:** read from the three guards named above rather than\nsimulated. What would settle it is `make test` with `-DCATCH_ILLINSN=0` and\n`ENABLE_MUL=1` and a program containing one `DIV` \u2014 the multiplier core does\nnot claim it \u2014 which is the experiment this entry is asking for and which has\nnot been run. Recording the bound without the simulation is what [[D-0004]]\nrequires to be said out loud.\n\n**Why it may be intended.** `CATCH_ILLINSN = 0` means \"I have removed the\nillegal-instruction circuitry\", and the README is explicit that an `EBREAK`\nstill traps at that setting while no longer raising IRQ 1. A reading in which\nthe parameter also removes the timeout \u2014 so that a co-processor is trusted to\nclaim everything offered to it \u2014 is coherent, and in a closed system where the\nintegrator knows the instruction set it is free area.\n\n**Why it may not be.** The combination is not documented, no named\nconfiguration point uses it ([[CON-CFG-001]]), and the failure is silent. The\nalternative implementation \u2014 keep the timeout whenever `WITH_PCPI`, and halt\nrather than interrupt at `CATCH_ILLINSN = 0` \u2014 costs the same counter and has\nno silent mode.\n\n**The question.** Whether [[TRAP-ILL-001]] should state the hang as a\n**property of the machine** at that parameter combination, or\n[[CON-BASIC-001]] should state the combination as **excluded**. Row 12 of\n[[CON-BASIC-001]] currently does the second, which has the effect of declaring\na parameter combination out of scope on this tree's authority rather than on\nthe RTL's \u2014 and this tree's authority does not extend to narrowing the design\nspace ([[D-0001]]).\n\nThe honest resolution is probably both: the constraint for a reader choosing\nparameters, and the property for a reader implementing the machine. What is\nundecided is whether the property is a defect to report upstream. The project\nis archived, so there is nowhere to report it." + } + ], + "external": true, + "instanceName": "DOC-PICORV32-Q-003", + "networkInstance": true, + "referencedEntityName": "DOC-PICORV32-Q-003" + }, + "ports": [ + { + "direction": "in", + "id": "port:node:scope:root:Q-003:in", + "name": "in" + }, + { + "direction": "out", + "id": "port:node:scope:root:Q-003:out", + "name": "out" + } + ], + "scope": "scope:root", + "type": "Q-003" + }, + { + "id": "node:scope:root:VER-TRAP-001", + "kind": "verification", + "label": "A trapped core is detected by `trap` and by nothing else", + "meta": { + "badges": [ + { + "text": "MUST", + "tone": "info" + }, + { + "text": "L3", + "tone": "neutral" + } + ], + "definitionAnnotations": [ + { + "name": "verification" + }, + { + "arguments": [ + { + "name": "id", + "value": "VER-TRAP-001" + }, + { + "name": "modality", + "value": "must" + }, + { + "name": "refinement", + "value": "L3" + }, + { + "name": "status", + "value": "active" + }, + { + "name": "owner", + "value": "picorv32-spec" + }, + { + "name": "since", + "value": "1.0" + } + ], + "name": "ndf" + }, + { + "name": "body", + "text": "A test MUST detect a halt by watching the `trap` output, and MUST NOT detect it by\nwaiting for activity to cease: a trapped core still counts cycles and still counts\nits timer down ([[TRAP-001]]).\n\nA test MUST additionally distinguish a halt from a **hang**, which `trap` does\nnot report. Two hangs are reachable and neither asserts it:\n\n```text ndf:normative\nhang clause\nan unclaimed instruction at CATCH_ILLINSN = 0 with a Q-003\nco-processor present: ld_rs1 with pcpi_valid asserted and\nno timeout\na memory peer that never asserts mem_ready MIF-001\n```\n\nSo a test MUST carry a step or cycle bound of its own, and a run that reaches it\nMUST be reported as a failure rather than as completion. `testbench.v` does this\nwith a `$finish` on a memory-mapped write and a cycle limit; the model does it\nwith the runtime's iteration bound." + } + ], + "external": true, + "instanceName": "DOC-PICORV32-VER-ACCEPTANCE", + "networkInstance": true, + "referencedEntityName": "DOC-PICORV32-VER-ACCEPTANCE" + }, + "ports": [ + { + "direction": "in", + "id": "port:node:scope:root:VER-TRAP-001:in", + "name": "in" + }, + { + "direction": "out", + "id": "port:node:scope:root:VER-TRAP-001:out", + "name": "out" + } + ], + "scope": "scope:root", + "type": "VER-TRAP-001" + } + ], + "subgraphs": [ + { + "id": "scope:group:L1", + "kind": "group", + "label": "L1", + "nodes": [ + "node:scope:root:TRAP-001", + "node:scope:root:TRAP-CAUSE-001" + ] + }, + { + "id": "scope:group:L2", + "kind": "group", + "label": "L2", + "nodes": [ + "node:scope:root:TRAP-ALIGN-001", + "node:scope:root:TRAP-ILL-001" + ] + } + ] + }, + "version": "1.0" +} diff --git a/packages/diagram-server/test/java-version.ts b/packages/diagram-server/test/java-version.ts new file mode 100644 index 0000000..dba80e9 --- /dev/null +++ b/packages/diagram-server/test/java-version.ts @@ -0,0 +1,18 @@ +import { spawnSync } from 'node:child_process'; +import * as path from 'node:path'; + +/** + * The major version of the Java the ELK server would be started with + * (`$JAVA_HOME/bin/java`, else `java`, as ElkServerProcess picks it), or 0 when + * there is none. The server's jar needs 17. + */ +export function javaMajor(): number { + const java = process.env.JAVA_HOME ? path.join(process.env.JAVA_HOME, 'bin', 'java') : 'java'; + const result = spawnSync(java, ['-version'], { encoding: 'utf8' }); + const match = /version "(\d+)(?:\.(\d+))?/.exec(`${result.stderr ?? ''}${result.stdout ?? ''}`); + if (result.status !== 0 || !match) { + return 0; + } + // "1.8.0" is Java 8. + return match[1] === '1' ? Number(match[2]) : Number(match[1]); +}