From f3f0a993f6ddb380539fbca6a0217d45a0314fc6 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 3 Oct 2026 22:51:51 +0000 Subject: [PATCH 1/2] feat(wasm): add combined sysml-wasm command Co-Authored-By: jason.han --- Makefile | 19 +- changes/unreleased/sysml-wasm.added.md | 1 + cmd/sysml-wasm/main.go | 49 +++ cmd/sysml-wasm/serve_js.go | 38 ++ cmd/sysml-wasm/serve_other.go | 9 + docs/reference/wasm.md | 41 +- internal/frontend/combined/combined.go | 148 +++++++ internal/frontend/combined/combined_test.go | 86 ++++ internal/frontend/core/core.go | 5 + internal/frontend/engine/engine.go | 20 +- tests/hygiene/layering_test.go | 2 + tests/wasm/combined_test.go | 416 ++++++++++++++++++++ tests/wasm/testdata/combined.mjs | 24 ++ tests/wasm/wasm_build_test.go | 2 +- tests/wasm/wasm_run_test.go | 3 + 15 files changed, 840 insertions(+), 23 deletions(-) create mode 100644 changes/unreleased/sysml-wasm.added.md create mode 100644 cmd/sysml-wasm/main.go create mode 100644 cmd/sysml-wasm/serve_js.go create mode 100644 cmd/sysml-wasm/serve_other.go create mode 100644 internal/frontend/combined/combined.go create mode 100644 internal/frontend/combined/combined_test.go create mode 100644 tests/wasm/combined_test.go create mode 100644 tests/wasm/testdata/combined.mjs diff --git a/Makefile b/Makefile index 4d159cf600..8ec28ac63c 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: all build build-sysml build-prod build-wasm-prod build-lsp build-grpc build-engine build-core build-syntax build-wasm build-wasm-wasip1 build-wasm-js wasm-check static-check windows-versioninfo-check man man-check install-tree pgo-profile conformance conformance-pkg conformance-rust conformance-julia conformance-matlab test test-shard coverage lint clean install help fuml-expected python-test python-coverage scripts-coverage node-coverage python-install proto proto-buf python-proto proto-ts proto-rust proto-lint proto-breaking vscode-grammar vscode-build vscode-package docs docs-install docs-serve docs-engine-assets docs-counts docs-check changelog-check changelog-render self-model +.PHONY: all build build-sysml build-prod build-wasm-prod build-lsp build-grpc build-engine build-core build-syntax build-sysml-wasm build-wasm build-wasm-wasip1 build-wasm-js wasm-check static-check windows-versioninfo-check man man-check install-tree pgo-profile conformance conformance-pkg conformance-rust conformance-julia conformance-matlab test test-shard coverage lint clean install help fuml-expected python-test python-coverage scripts-coverage node-coverage python-install proto proto-buf python-proto proto-ts proto-rust proto-lint proto-breaking vscode-grammar vscode-build vscode-package docs docs-install docs-serve docs-engine-assets docs-counts docs-check changelog-check changelog-render self-model # Version information # Only release tags describe a build; the moving `nightly` tag is not a version. @@ -70,11 +70,10 @@ TOOLS_DIR := tools # The commands whose manual pages are generated and shipped, in section 1. COMMANDS := sysml sysml-lsp sysml-grpc -# sysml-engine, sysml-syntax and sysml-core serve the execution, syntactic and -# validation RPCs over JSON so a client needs no protobuf. Natively they are -# built only on request (`build-engine`, `build-core`, `build-syntax`) and stay -# out of `build`, `install`, the release and the manual pages. -WASM_COMMANDS := $(COMMANDS) sysml-engine sysml-syntax sysml-core +# sysml-engine, sysml-syntax, sysml-core and sysml-wasm serve JSON RPC surfaces +# without protobuf. Natively they are built only on request and stay out of +# `build`, `install`, the release and the manual pages. +WASM_COMMANDS := $(COMMANDS) sysml-engine sysml-syntax sysml-core sysml-wasm MAN_DIR := packaging/man/man1 MAN_PAGES := $(addprefix $(MAN_DIR)/,$(addsuffix .1,$(COMMANDS))) @@ -136,6 +135,12 @@ build-core: ## Build bin/sysml-core natively (opt-in; not released) $(GO_BUILD) -o $(BIN_DIR)/sysml-core ./cmd/sysml-core @echo "✓ Built $(BIN_DIR)/sysml-core ($(VERSION))" +build-sysml-wasm: ## Build bin/sysml-wasm natively (opt-in; not released) + @echo "Building sysml-wasm..." + @mkdir -p $(BIN_DIR) + $(GO_BUILD) -o $(BIN_DIR)/sysml-wasm ./cmd/sysml-wasm + @echo "✓ Built $(BIN_DIR)/sysml-wasm ($(VERSION))" + build-syntax: ## Build bin/sysml-syntax natively (opt-in; not released) @echo "Building sysml-syntax..." @mkdir -p $(BIN_DIR) @@ -146,7 +151,7 @@ build-syntax: ## Build bin/sysml-syntax natively (opt-in; not released) # host), GOOS=js under Node or a browser through the toolchain's wasm_exec.js. The # version stamps are the -X flags every other build passes; there is no Windows # resource to embed and no libc to link. `build` stays native: these are opt-in. -build-wasm: build-wasm-wasip1 build-wasm-js ## Build all three commands for both WebAssembly targets +build-wasm: build-wasm-wasip1 build-wasm-js ## Build all seven commands for both WebAssembly targets build-wasm-wasip1: ## Build bin/wasm/wasip1/*.wasm, runnable under a WASI preview 1 runtime @echo "Building WebAssembly (wasip1)..." diff --git a/changes/unreleased/sysml-wasm.added.md b/changes/unreleased/sysml-wasm.added.md new file mode 100644 index 0000000000..a871d5a8d6 --- /dev/null +++ b/changes/unreleased/sysml-wasm.added.md @@ -0,0 +1 @@ +- **`sysml-wasm` combines the execution and validation JSON-RPC surfaces in one WebAssembly module.** It routes parsing and symbol facts to the core and evaluation and execution to the engine, shares a single standard-library snapshot, and reports supported capabilities through `GetServerInfo`. JavaScript hosts call `globalThis.sysmlWasm.call(method, paramsJSON)` synchronously; native and WASI builds serve JSON-RPC over stdio. See `docs/reference/wasm.md`. diff --git a/cmd/sysml-wasm/main.go b/cmd/sysml-wasm/main.go new file mode 100644 index 0000000000..6d510ed6c5 --- /dev/null +++ b/cmd/sysml-wasm/main.go @@ -0,0 +1,49 @@ +// Copyright 2025 Open‐MBEE Foundation. All rights reserved. +// Use of this source code is governed by the LICENSE file. + +package main + +import ( + "context" + "flag" + "fmt" + "os" + + "github.com/Open-MBEE/OpenSysML/internal/frontend/combined" +) + +var ( + Version = "dev" + Commit = "unknown" + BuildTime = "unknown" + GoVersion = "unknown" +) + +func main() { + showVersion := flag.Bool("version", false, "Show version and exit") + useStdio := flag.Bool("stdio", false, "Serve stdio (the default outside the js WebAssembly target)") + flag.Parse() + + if *showVersion { + fmt.Printf("sysml-wasm %s\n", Version) + fmt.Printf(" Commit: %s\n", Commit) + fmt.Printf(" Build time: %s\n", BuildTime) + fmt.Printf(" Go version: %s\n", GoVersion) + return + } + + server, err := combined.New(Version) + if err != nil { + fmt.Fprintf(os.Stderr, "sysml-wasm: %v\n", err) + os.Exit(1) + } + os.Exit(serve(server, *useStdio)) +} + +func serveStdio(server *combined.Server) int { + if err := server.Serve(context.Background(), os.Stdin, os.Stdout); err != nil { + fmt.Fprintf(os.Stderr, "sysml-wasm: stdio session ended in a protocol error: %v\n", err) + return 1 + } + return 0 +} diff --git a/cmd/sysml-wasm/serve_js.go b/cmd/sysml-wasm/serve_js.go new file mode 100644 index 0000000000..85e6c8a01b --- /dev/null +++ b/cmd/sysml-wasm/serve_js.go @@ -0,0 +1,38 @@ +//go:build js + +package main + +import ( + "context" + "syscall/js" + + "github.com/Open-MBEE/OpenSysML/internal/frontend/combined" + "github.com/Open-MBEE/OpenSysML/internal/frontend/jsonrpc" +) + +func serve(server *combined.Server, useStdio bool) int { + if useStdio { + return serveStdio(server) + } + js.Global().Set("sysmlWasm", map[string]any{ + "version": Version, + "call": js.FuncOf(func(_ js.Value, args []js.Value) any { + method, params := "", "" + if len(args) > 0 { + method = args[0].String() + } + if len(args) > 1 { + params = args[1].String() + } + var body []byte + var err error + if params == "" { + body, err = server.Call(context.Background(), method, nil) + } else { + body, err = server.Call(context.Background(), method, []byte(params)) + } + return jsonrpc.Envelope(body, err) + }), + }) + select {} +} diff --git a/cmd/sysml-wasm/serve_other.go b/cmd/sysml-wasm/serve_other.go new file mode 100644 index 0000000000..dfba6cccfd --- /dev/null +++ b/cmd/sysml-wasm/serve_other.go @@ -0,0 +1,9 @@ +//go:build !js + +package main + +import "github.com/Open-MBEE/OpenSysML/internal/frontend/combined" + +func serve(server *combined.Server, _ bool) int { + return serveStdio(server) +} diff --git a/docs/reference/wasm.md b/docs/reference/wasm.md index e1fd606878..ab6b452415 100644 --- a/docs/reference/wasm.md +++ b/docs/reference/wasm.md @@ -16,12 +16,12 @@ make build-wasm-wasip1 # or one make build-wasm-js ``` -The output is one directory per target, six commands each, stamped with the same version +The output is one directory per target, seven commands each, stamped with the same version information a native build carries: ``` -bin/wasm/wasip1/{sysml,sysml-lsp,sysml-grpc,sysml-engine,sysml-syntax,sysml-core}.wasm -bin/wasm/js/{sysml,sysml-lsp,sysml-grpc,sysml-engine,sysml-syntax,sysml-core}.wasm +bin/wasm/wasip1/{sysml,sysml-lsp,sysml-grpc,sysml-engine,sysml-syntax,sysml-core,sysml-wasm}.wasm +bin/wasm/js/{sysml,sysml-lsp,sysml-grpc,sysml-engine,sysml-syntax,sysml-core,sysml-wasm}.wasm bin/wasm/js/wasm_exec.js # the runtime a browser page includes ``` @@ -31,10 +31,10 @@ beside it. `make build` is unchanged and stays native; a WebAssembly build is always asked for. -`make build-engine`, `make build-core` and `make build-syntax` build `sysml-engine`, -`sysml-core` and `sysml-syntax` natively into `bin/`, where each serves its JSON-RPC -over standard input and output as its WASI build does. They are opt-in as well: -`make build` and `make install` leave them out, and no release ships them. +`make build-engine`, `make build-core`, `make build-syntax` and `make build-sysml-wasm` +build the JSON commands natively into `bin/`, where each serves its JSON-RPC over standard +input and output as its WASI build does. They are opt-in as well: `make build` and +`make install` leave them out, and no release ships them. `make build-wasm-prod` builds a smaller `sysml-prod.wasm` for each target with `-tags sysml_prod` (`make build-prod` is the native counterpart). It leaves out SysML v1 migration, repository sync, @@ -209,6 +209,33 @@ paths readable; the standard library itself is embedded. Measured on a `go1.25` `js/wasm` build: 20,601,256 raw bytes, 5,444,030 bytes with gzip `-9`, and 3,843,951 bytes with Brotli. +## The combined module + +`sysml-wasm` combines the parsing, validation and execution methods of `sysml-core` and +`sysml-engine` in one WebAssembly module. It serves `ParseSources`, `ParseFile`, +`GetDiagnostics`, `GetSymbol`, `Evaluate`, `Instantiate`, `ExecuteAction`, `ExecuteState` +and `GetServerInfo`. Parsing and symbol facts route through the core; evaluation and +execution route through the engine. `ParseSources` and `ParseFile` check that both frontends +produce the same model hash and return the core response, including roots and diagnostics. + +A hash from either parse method is shared by every method that takes a `modelHash`, including +`GetDiagnostics`, `GetSymbol`, `Evaluate`, `Instantiate`, `ExecuteAction` and `ExecuteState`. +`GetServerInfo` returns the build version and these capabilities, in order: +`type_facts`, `enum_values`, `evaluate_subject`, `symbol_attributes`, `unset_value`, +`feature_values`, `inline_language`, `strict_conformance`, `parse_sources`, `complex_values`, +`structured_values`, `measurement_refs`, `function_values`, `set_values`, `tensor_values`, +`infinity_value`, `diagnostic_codes`, `schedule`, `final_time`, `metaobject_values`, +`undetermined_value`, `performer`, `big_int_values`. + +The `js` build installs one synchronous host surface, `globalThis.sysmlWasm`, with +`version` and `call(method, paramsJSON)`. The call returns a JSON-RPC envelope string. +Passing `-stdio` selects the sequential, `Content-Length`-framed JSON-RPC pipe instead; +native and `wasip1` builds always use that pipe. Every other method answers Unimplemented +with ` is not served by sysml-wasm: it is served by sysml-grpc`. + +Measured on a `go1.25.11` `js/wasm` build with `-s -w -trimpath`: 31,526,528 raw bytes, +7,790,873 bytes gzipped with gzip `-9`, and 5,479,244 bytes with Brotli `-q 11`. + ## What works Everything that is the language implementation rather than the host around it: diff --git a/internal/frontend/combined/combined.go b/internal/frontend/combined/combined.go new file mode 100644 index 0000000000..bfd30ed571 --- /dev/null +++ b/internal/frontend/combined/combined.go @@ -0,0 +1,148 @@ +// Copyright 2025 Open‐MBEE Foundation. All rights reserved. +// Use of this source code is governed by the LICENSE file. + +// Package combined serves the shared parsing, validation and execution surface. +package combined + +import ( + "context" + "encoding/json" + "io" + + "github.com/Open-MBEE/OpenSysML/internal/frontend/core" + "github.com/Open-MBEE/OpenSysML/internal/frontend/engine" + "github.com/Open-MBEE/OpenSysML/internal/frontend/jsonrpc" + "github.com/Open-MBEE/OpenSysML/internal/workspace/libs" +) + +var capabilities = []string{ + "type_facts", + "enum_values", + "evaluate_subject", + "symbol_attributes", + "unset_value", + "feature_values", + "inline_language", + "strict_conformance", + "parse_sources", + "complex_values", + "structured_values", + "measurement_refs", + "function_values", + "set_values", + "tensor_values", + "infinity_value", + "diagnostic_codes", + "schedule", + "final_time", + "metaobject_values", + "undetermined_value", + "performer", + "big_int_values", +} + +// Server combines the parsing/validation and execution JSON surfaces. +type Server struct { + engine *engine.Engine + core *core.Core + version string +} + +// New builds both frontends over one frozen standard-library snapshot. +func New(version string) (*Server, error) { + index, src := libs.FrozenLibrary() + execution, err := engine.NewWithLibrary(index, src) + if err != nil { + return nil, err + } + validation, err := core.NewWithLibrary(index, src) + if err != nil { + return nil, err + } + return &Server{engine: execution, core: validation, version: version}, nil +} + +// Call runs one combined method with protojson-shaped request parameters. +func (s *Server) Call(ctx context.Context, method string, params []byte) ([]byte, error) { + switch method { + case "ParseSources": + body, err := s.core.Call(ctx, method, params) + if err != nil { + return nil, err + } + engineBody, err := s.engine.Call(ctx, method, params) + if err != nil { + return nil, err + } + if err := sameModelHash(body, engineBody); err != nil { + return nil, err + } + return body, nil + case "ParseFile": + body, err := s.core.Call(ctx, method, params) + if err != nil { + return nil, err + } + var req core.JParseFileRequest + if err := jsonrpc.Decode(params, &req); err != nil { + return nil, err + } + document := &engine.JSourceDocument{} + if req.FilePath != nil { + document.FilePath = req.FilePath + } else if req.Content != nil { + document.Name = "" + document.Content = req.Content + document.Language = req.Language + } + engineParams, err := json.Marshal(&engine.JParseSourcesRequest{ + Documents: []*engine.JSourceDocument{document}, + StrictConformance: req.StrictConformance, + }) + if err != nil { + return nil, jsonrpc.Errorf(jsonrpc.CodeInternal, "encoding engine ParseSources request: %v", err) + } + engineBody, err := s.engine.Call(ctx, "ParseSources", engineParams) + if err != nil { + return nil, err + } + if err := sameModelHash(body, engineBody); err != nil { + return nil, err + } + return body, nil + case "GetDiagnostics", "GetSymbol": + return s.core.Call(ctx, method, params) + case "Evaluate", "Instantiate", "ExecuteAction", "ExecuteState": + return s.engine.Call(ctx, method, params) + case "GetServerInfo": + return json.Marshal(struct { + Version string `json:"version,omitempty"` + Capabilities []string `json:"capabilities,omitempty"` + }{Version: s.version, Capabilities: capabilities}) + default: + return nil, jsonrpc.Errorf(jsonrpc.CodeUnimplemented, + "%s is not served by sysml-wasm: it is served by sysml-grpc", method) + } +} + +func sameModelHash(coreBody, engineBody []byte) error { + var coreResponse, engineResponse struct { + ModelHash string `json:"modelHash"` + } + if err := json.Unmarshal(coreBody, &coreResponse); err != nil { + return jsonrpc.Errorf(jsonrpc.CodeInternal, "decoding core model hash: %v", err) + } + if err := json.Unmarshal(engineBody, &engineResponse); err != nil { + return jsonrpc.Errorf(jsonrpc.CodeInternal, "decoding engine model hash: %v", err) + } + if coreResponse.ModelHash != engineResponse.ModelHash { + return jsonrpc.Errorf(jsonrpc.CodeInternal, + "sysml-wasm model hash mismatch: core %q, engine %q", coreResponse.ModelHash, engineResponse.ModelHash) + } + return nil +} + +// Serve answers Content-Length-delimited JSON-RPC frames until r reaches its end. +func (s *Server) Serve(ctx context.Context, r io.Reader, w io.Writer) error { + return jsonrpc.Serve(ctx, r, w, s.Call) +} diff --git a/internal/frontend/combined/combined_test.go b/internal/frontend/combined/combined_test.go new file mode 100644 index 0000000000..50d12a5929 --- /dev/null +++ b/internal/frontend/combined/combined_test.go @@ -0,0 +1,86 @@ +// Copyright 2025 Open‐MBEE Foundation. All rights reserved. +// Use of this source code is governed by the LICENSE file. + +package combined + +import ( + "context" + "encoding/json" + "errors" + "reflect" + "testing" + + "github.com/Open-MBEE/OpenSysML/internal/frontend/jsonrpc" +) + +func TestUnservedMethod(t *testing.T) { + server, err := New("combined-test") + if err != nil { + t.Fatalf("New: %v", err) + } + _, err = server.Call(context.Background(), "Convert", []byte(`{}`)) + var callErr *jsonrpc.Error + if !errors.As(err, &callErr) || callErr.Code != jsonrpc.CodeUnimplemented { + t.Fatalf("Call(Convert) error = %v, want Unimplemented", err) + } + if callErr.Message != "Convert is not served by sysml-wasm: it is served by sysml-grpc" { + t.Errorf("Call(Convert) message = %q", callErr.Message) + } +} + +func TestGetServerInfo(t *testing.T) { + server, err := New("combined-test") + if err != nil { + t.Fatalf("New: %v", err) + } + body, err := server.Call(context.Background(), "GetServerInfo", nil) + if err != nil { + t.Fatalf("GetServerInfo: %v", err) + } + var info struct { + Version string `json:"version"` + Capabilities []string `json:"capabilities"` + } + if err := json.Unmarshal(body, &info); err != nil { + t.Fatalf("decoding GetServerInfo: %v\n%s", err, body) + } + if info.Version != "combined-test" { + t.Errorf("version = %q, want combined-test", info.Version) + } + if !reflect.DeepEqual(info.Capabilities, capabilities) { + t.Errorf("capabilities = %v, want %v", info.Capabilities, capabilities) + } +} + +func TestParseFileHashIsAcceptedByEvaluate(t *testing.T) { + server, err := New("combined-test") + if err != nil { + t.Fatalf("New: %v", err) + } + parsed, err := server.Call(context.Background(), "ParseFile", []byte(`{"content":"package Demo {}"}`)) + if err != nil { + t.Fatalf("ParseFile: %v", err) + } + var response struct { + ModelHash string `json:"modelHash"` + } + if err := json.Unmarshal(parsed, &response); err != nil { + t.Fatalf("decoding ParseFile response: %v\n%s", err, parsed) + } + evaluated, err := server.Call(context.Background(), "Evaluate", []byte(`{"modelHash":"`+response.ModelHash+`","expression":"1 + 1"}`)) + if err != nil { + t.Fatalf("Evaluate: %v", err) + } + var answer struct { + Result struct { + IntValue string `json:"intValue"` + } `json:"result"` + Error string `json:"error"` + } + if err := json.Unmarshal(evaluated, &answer); err != nil { + t.Fatalf("decoding Evaluate response: %v\n%s", err, evaluated) + } + if answer.Error != "" || answer.Result.IntValue != "2" { + t.Errorf("Evaluate = %s, want intValue 2", evaluated) + } +} diff --git a/internal/frontend/core/core.go b/internal/frontend/core/core.go index befc285d76..d5add43164 100644 --- a/internal/frontend/core/core.go +++ b/internal/frontend/core/core.go @@ -70,6 +70,11 @@ type Core struct { // New builds a core server over the frozen standard-library snapshot. func New() (*Core, error) { index, library := libs.FrozenLibrary() + return NewWithLibrary(index, library) +} + +// NewWithLibrary builds a core server over a shared standard-library snapshot. +func NewWithLibrary(index *symbols.Index, library libs.Source) (*Core, error) { return &Core{ libraryIndex: index, library: library, diff --git a/internal/frontend/engine/engine.go b/internal/frontend/engine/engine.go index a80bcd39cb..821b6c920d 100644 --- a/internal/frontend/engine/engine.go +++ b/internal/frontend/engine/engine.go @@ -173,14 +173,22 @@ type Engine struct { // New builds an engine over the frozen standard library snapshot, under the // runtime budgets a default sysml-grpc NewService runs with. func New() (*Engine, error) { + index, src := libs.FrozenLibrary() + return NewWithLibrary(index, src) +} + +// NewWithLibrary builds an engine over a shared standard library snapshot. +func NewWithLibrary(index *symbols.Index, src libs.Source) (*Engine, error) { budgets, err := runtime.BudgetsFromEnv() if err != nil { return nil, err } return &Engine{ - budgets: budgets, - models: list.New(), - byHash: make(map[string]*list.Element), + libIndex: index, + libSrc: src, + budgets: budgets, + models: list.New(), + byHash: make(map[string]*list.Element), }, nil } @@ -210,12 +218,8 @@ func (e *Engine) add(hash string, model *cachedModel) { e.byHash[hash] = e.models.PushFront(&cacheEntry{hash: hash, model: model}) } -// lib returns the frozen library index for one model to overlay, building it -// once on the first parse. +// lib returns the frozen library index for one model to overlay. func (e *Engine) lib() (*symbols.Index, libs.Source) { - if e.libIndex == nil { - e.libIndex, e.libSrc = libs.FrozenLibrary() - } return e.libIndex, e.libSrc } diff --git a/tests/hygiene/layering_test.go b/tests/hygiene/layering_test.go index 4f16c2a709..18af68dd5e 100644 --- a/tests/hygiene/layering_test.go +++ b/tests/hygiene/layering_test.go @@ -125,6 +125,7 @@ var packageLayer = map[string]string{ "internal/frontend/grpc": "frontend", "internal/frontend/engine": "frontend", "internal/frontend/core": "frontend", + "internal/frontend/combined": "frontend", "internal/frontend/jsonrpc": "frontend", "internal/frontend/symbolfacts": "frontend", "internal/frontend/syntax": "frontend", @@ -136,6 +137,7 @@ var packageLayer = map[string]string{ "cmd/sysml-engine": "frontend", "cmd/sysml-syntax": "frontend", "cmd/sysml-core": "frontend", + "cmd/sysml-wasm": "frontend", "internal/frontend/repl/replext": "frontend", "internal/frontend/repl/replext/all": "frontend", diff --git a/tests/wasm/combined_test.go b/tests/wasm/combined_test.go new file mode 100644 index 0000000000..c8c5962a83 --- /dev/null +++ b/tests/wasm/combined_test.go @@ -0,0 +1,416 @@ +package wasm + +import ( + "bytes" + "compress/gzip" + "context" + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "reflect" + "strings" + "testing" + + pb "github.com/Open-MBEE/OpenSysML/api/proto" + "github.com/Open-MBEE/OpenSysML/internal/frontend/combined" + sysmlgrpc "github.com/Open-MBEE/OpenSysML/internal/frontend/grpc" + "github.com/Open-MBEE/OpenSysML/internal/frontend/jsonrpc" +) + +const combinedGzipBudget = 8300000 + +const combinedModel = `package Demo { part def Item; }` + +func combinedSubtests(t *testing.T, target wasmTarget, r runner, bins map[string]string) { + t.Run("answers a combined session over stdio", func(t *testing.T) { + server, err := combined.New(versionStamp) + if err != nil { + t.Fatalf("combined.New: %v", err) + } + parseBody, err := server.Call(context.Background(), "ParseFile", []byte(fmt.Sprintf( + `{"content":%s}`, mustJSON(t, combinedModel)))) + if err != nil { + t.Fatalf("deriving the inline model hash: %v", err) + } + var parsed struct { + ModelHash string `json:"modelHash"` + } + if err := json.Unmarshal(parseBody, &parsed); err != nil { + t.Fatalf("decoding the inline model hash: %v\n%s", err, parseBody) + } + calls := []engineCall{ + {method: "ParseFile", params: fmt.Sprintf(`{"content":%s}`, mustJSON(t, combinedModel))}, + {method: "Evaluate", params: fmt.Sprintf(`{"modelHash":%q,"expression":"1 + 1"}`, parsed.ModelHash)}, + {method: "GetSymbol", params: fmt.Sprintf(`{"modelHash":%q,"symbolId":"Demo::Item"}`, parsed.ModelHash)}, + {method: "GetDiagnostics", params: fmt.Sprintf(`{"modelHash":%q}`, parsed.ModelHash)}, + {method: "Instantiate", params: fmt.Sprintf(`{"modelHash":%q,"symbolId":"Demo::Item"}`, parsed.ModelHash)}, + {method: "GetServerInfo", params: `{}`}, + {method: "Convert", params: `{}`}, + {method: "ParseFile", params: `"{"`}, + {method: "GetDiagnostics", params: fmt.Sprintf(`{"modelHash":%q}`, parsed.ModelHash)}, + } + args := []string(nil) + if target.goos == "js" { + args = append(args, "-stdio") + } + got := r.runWithInput(t, bins["sysml-wasm"], engineSession(t, calls...), args...) + if got.code != 0 { + t.Fatalf("sysml-wasm stdio exited %d:\n%s", got.code, got.output) + } + bodies := frames(t, got.output) + if len(bodies) != len(calls) { + t.Fatalf("combined session answered %d frames, want %d:\n%s", len(bodies), len(calls), got.output) + } + for i, body := range bodies { + var answer struct { + Result json.RawMessage `json:"result"` + Error *struct { + Code uint32 `json:"code"` + Message string `json:"message"` + } `json:"error"` + } + if err := json.Unmarshal([]byte(body), &answer); err != nil { + t.Fatalf("decoding combined frame %d: %v\n%s", i, err, body) + } + switch i { + case 0, 1, 2, 3, 4, 5, 8: + if answer.Error != nil { + t.Fatalf("combined call %s failed: %+v", calls[i].method, answer.Error) + } + case 6: + if answer.Error == nil || answer.Error.Code != jsonrpc.CodeUnimplemented { + t.Errorf("unserved method error = %+v, want Unimplemented", answer.Error) + } + case 7: + if answer.Error == nil || answer.Error.Code != jsonrpc.CodeInvalidArgument { + t.Errorf("malformed params error = %+v, want InvalidArgument", answer.Error) + } + } + if i == 1 && !strings.Contains(string(answer.Result), `"intValue":"2"`) { + t.Errorf("Evaluate result = %s, want intValue 2", answer.Result) + } + if i == 2 && !strings.Contains(string(answer.Result), `"symbol"`) { + t.Errorf("GetSymbol returned no symbol: %s", answer.Result) + } + if i == 4 && !strings.Contains(string(answer.Result), `"instance"`) { + t.Errorf("Instantiate returned no instance: %s", answer.Result) + } + if i == 5 && !strings.Contains(string(answer.Result), `"capabilities"`) { + t.Errorf("GetServerInfo returned no capabilities: %s", answer.Result) + } + } + }) + + if target.goos != "js" { + return + } + + t.Run("answers through globalThis.sysmlWasm", func(t *testing.T) { + got := nodeRun(t, fixture(t, "combined.mjs"), wasmExecJS(t), bins["sysml-wasm"]) + answers := make(map[string]string) + for _, line := range strings.Split(got.output, "\n") { + method, rest, _ := strings.Cut(line, " ") + answers[method] = rest + } + if answers["version"] != versionStamp { + t.Errorf("sysmlWasm.version = %q, want %q", answers["version"], versionStamp) + } + for _, method := range []string{"ParseFile", "Evaluate", "GetSymbol", "GetDiagnostics", "afterMalformed"} { + var answer struct { + Result json.RawMessage `json:"result"` + Error json.RawMessage `json:"error"` + } + if err := json.Unmarshal([]byte(answers[method]), &answer); err != nil { + t.Errorf("%s returned invalid JSON: %v\n%s", method, err, answers[method]) + } else if len(answer.Error) != 0 { + t.Errorf("%s returned an error: %s", method, answer.Error) + } + } + if !strings.Contains(answers["malformed"], `"code":3`) { + t.Errorf("malformed params answered %s, want InvalidArgument (3)", answers["malformed"]) + } + }) + + t.Run("fits the combined size budget", func(t *testing.T) { + data, err := os.ReadFile(bins["sysml-wasm"]) + if err != nil { + t.Fatalf("reading %s: %v", bins["sysml-wasm"], err) + } + var compressed bytes.Buffer + w, err := gzip.NewWriterLevel(&compressed, gzip.BestCompression) + if err != nil { + t.Fatalf("gzip writer: %v", err) + } + if _, err := w.Write(data); err != nil { + t.Fatalf("compressing: %v", err) + } + if err := w.Close(); err != nil { + t.Fatalf("compressing: %v", err) + } + if size := compressed.Len(); size > combinedGzipBudget { + t.Errorf("gzipped sysml-wasm.wasm is %d bytes, over the %d-byte budget", size, combinedGzipBudget) + } else { + t.Logf("sysml-wasm.wasm is %d raw bytes and %d bytes gzipped -9", len(data), size) + } + }) +} + +func TestCombinedWireParity(t *testing.T) { + ctx := context.Background() + server, err := combined.New("combined-wire-test") + if err != nil { + t.Fatalf("combined.New: %v", err) + } + svc, err := sysmlgrpc.NewService(16, "combined-wire-test") + if err != nil { + t.Fatalf("grpc.NewService: %v", err) + } + + call := func(method, params string) []byte { + t.Helper() + body, err := server.Call(ctx, method, []byte(params)) + if err != nil { + t.Fatalf("combined %s refused: %v", method, err) + } + return body + } + equal := func(method, params string, grpcBody, combinedBody []byte) { + t.Helper() + var grpcJSON, combinedJSON any + if err := json.Unmarshal(grpcBody, &grpcJSON); err != nil { + t.Fatalf("decoding grpc %s: %v\n%s", method, err, grpcBody) + } + if err := json.Unmarshal(combinedBody, &combinedJSON); err != nil { + t.Fatalf("decoding combined %s: %v\n%s", method, err, combinedBody) + } + if !reflect.DeepEqual(combinedJSON, grpcJSON) { + t.Errorf("%s response mismatch for %s:\ncombined: %s\ngrpc: %s", method, params, combinedBody, grpcBody) + } + } + + parseParams := engineParseParams(t) + combinedParse := call("ParseSources", parseParams) + grpcParse, err := svc.ParseSources(ctx, mustUnmarshal[pb.ParseSourcesRequest](t, parseParams)) + if err != nil { + t.Fatalf("grpc ParseSources: %v", err) + } + grpcParseBody := mustMarshal(t, grpcParse) + equal("ParseSources", parseParams, grpcParseBody, combinedParse) + var parsed struct { + ModelHash string `json:"modelHash"` + } + if err := json.Unmarshal(combinedParse, &parsed); err != nil { + t.Fatalf("decoding ParseSources response: %v\n%s", err, combinedParse) + } + hash := parsed.ModelHash + + diagnosticsParams := fmt.Sprintf(`{"modelHash":%q}`, hash) + grpcDiagnostics, err := svc.GetDiagnostics(ctx, mustUnmarshal[pb.DiagnosticsRequest](t, diagnosticsParams)) + if err != nil { + t.Fatalf("grpc GetDiagnostics: %v", err) + } + equal("GetDiagnostics", diagnosticsParams, mustMarshal(t, grpcDiagnostics), call("GetDiagnostics", diagnosticsParams)) + + for _, expression := range []string{"7", "1.5", "true", "enginedemo::speed", "2 ** 70"} { + params := fmt.Sprintf(`{"modelHash":%q,"expression":%s}`, hash, mustJSON(t, expression)) + response, err := svc.Evaluate(ctx, mustUnmarshal[pb.EvaluateRequest](t, params)) + if err != nil { + t.Fatalf("grpc Evaluate %q: %v", expression, err) + } + equal("Evaluate", params, mustMarshal(t, response), call("Evaluate", params)) + } + + for _, test := range []struct { + method string + params string + call func() ([]byte, error) + }{ + { + method: "GetSymbol", + params: fmt.Sprintf(`{"modelHash":%q,"symbolId":"enginedemo::Double"}`, hash), + call: func() ([]byte, error) { + response, err := svc.GetSymbol(ctx, mustUnmarshal[pb.GetSymbolRequest](t, + fmt.Sprintf(`{"modelHash":%q,"symbolId":"enginedemo::Double"}`, hash))) + if err != nil { + return nil, err + } + return mustMarshal(t, response), nil + }, + }, + { + method: "Instantiate", + params: fmt.Sprintf(`{"modelHash":%q,"symbolId":"enginedemo::Switch"}`, hash), + call: func() ([]byte, error) { + response, err := svc.Instantiate(ctx, mustUnmarshal[pb.InstantiateRequest](t, + fmt.Sprintf(`{"modelHash":%q,"symbolId":"enginedemo::Switch"}`, hash))) + if err != nil { + return nil, err + } + return mustMarshal(t, response), nil + }, + }, + { + method: "ExecuteAction", + params: fmt.Sprintf(`{"modelHash":%q,"actionSymbolId":"enginedemo::Double","inputs":{"x":{"intValue":"6"}}}`, hash), + call: func() ([]byte, error) { + request := fmt.Sprintf(`{"modelHash":%q,"actionSymbolId":"enginedemo::Double","inputs":{"x":{"intValue":"6"}}}`, hash) + response, err := svc.ExecuteAction(ctx, mustUnmarshal[pb.ExecuteActionRequest](t, request)) + if err != nil { + return nil, err + } + return mustMarshal(t, response), nil + }, + }, + { + method: "ExecuteState", + params: fmt.Sprintf(`{"modelHash":%q,"stateMachineSymbolId":"enginedemo::Switch"}`, hash), + call: func() ([]byte, error) { + request := fmt.Sprintf(`{"modelHash":%q,"stateMachineSymbolId":"enginedemo::Switch"}`, hash) + response, err := svc.ExecuteState(ctx, mustUnmarshal[pb.ExecuteStateRequest](t, request)) + if err != nil { + return nil, err + } + return mustMarshal(t, response), nil + }, + }, + } { + grpcBody, err := test.call() + if err != nil { + t.Fatalf("grpc %s: %v", test.method, err) + } + equal(test.method, test.params, grpcBody, call(test.method, test.params)) + } + + inlineParams := fmt.Sprintf(`{"content":%s}`, mustJSON(t, readFile(t, "engine.sysml"))) + grpcFile, err := svc.ParseFile(ctx, mustUnmarshal[pb.ParseFileRequest](t, inlineParams)) + if err != nil { + t.Fatalf("grpc ParseFile inline: %v", err) + } + inlineBody := call("ParseFile", inlineParams) + equal("ParseFile inline", inlineParams, mustMarshal(t, grpcFile), inlineBody) + var inlineParsed struct { + ModelHash string `json:"modelHash"` + } + if err := json.Unmarshal(inlineBody, &inlineParsed); err != nil { + t.Fatalf("decoding inline ParseFile: %v\n%s", err, inlineBody) + } + for method, params := range map[string]string{ + "Evaluate": fmt.Sprintf(`{"modelHash":%q,"expression":"enginedemo::speed"}`, inlineParsed.ModelHash), + "GetSymbol": fmt.Sprintf(`{"modelHash":%q,"symbolId":"enginedemo::Double"}`, inlineParsed.ModelHash), + } { + switch method { + case "Evaluate": + response, err := svc.Evaluate(ctx, mustUnmarshal[pb.EvaluateRequest](t, params)) + if err != nil { + t.Fatalf("grpc Evaluate using ParseFile hash: %v", err) + } + equal(method+" after ParseFile", params, mustMarshal(t, response), call(method, params)) + case "GetSymbol": + response, err := svc.GetSymbol(ctx, mustUnmarshal[pb.GetSymbolRequest](t, params)) + if err != nil { + t.Fatalf("grpc GetSymbol using ParseFile hash: %v", err) + } + equal(method+" after ParseFile", params, mustMarshal(t, response), call(method, params)) + } + } + + path, err := filepath.Abs(filepath.Join("testdata", "engine.sysml")) + if err != nil { + t.Fatalf("resolving engine fixture: %v", err) + } + fileParams := fmt.Sprintf(`{"filePath":%q}`, path) + grpcPathFile, err := svc.ParseFile(ctx, mustUnmarshal[pb.ParseFileRequest](t, fileParams)) + if err != nil { + t.Fatalf("grpc ParseFile filePath: %v", err) + } + equal("ParseFile filePath", fileParams, mustMarshal(t, grpcPathFile), call("ParseFile", fileParams)) + + infoBody := call("GetServerInfo", `{}`) + var info struct { + Version string `json:"version"` + Capabilities []string `json:"capabilities"` + } + if err := json.Unmarshal(infoBody, &info); err != nil { + t.Fatalf("decoding combined GetServerInfo: %v\n%s", err, infoBody) + } + expectedCapabilities := []string{ + "type_facts", "enum_values", "evaluate_subject", "symbol_attributes", + "unset_value", "feature_values", "inline_language", "strict_conformance", + "parse_sources", "complex_values", "structured_values", "measurement_refs", + "function_values", "set_values", "tensor_values", "infinity_value", + "diagnostic_codes", "schedule", "final_time", "metaobject_values", + "undetermined_value", "performer", "big_int_values", + } + if info.Version != "combined-wire-test" { + t.Errorf("GetServerInfo version = %q", info.Version) + } + if !reflect.DeepEqual(info.Capabilities, expectedCapabilities) { + t.Errorf("GetServerInfo capabilities = %v, want %v", info.Capabilities, expectedCapabilities) + } + reported := make(map[string]bool) + for _, capability := range sysmlgrpc.Capabilities() { + reported[capability] = true + } + for _, capability := range expectedCapabilities { + if !reported[capability] { + t.Errorf("capability %q is not reported by sysml-grpc", capability) + } + } +} + +func TestCombinedDependencies(t *testing.T) { + root, err := filepath.Abs(filepath.Join("..", "..")) + if err != nil { + t.Fatalf("resolving repository root: %v", err) + } + const module = "github.com/Open-MBEE/OpenSysML" + required := []string{ + module + "/internal/frontend/engine", + module + "/internal/frontend/core", + } + for _, target := range []wasmTarget{{name: "js", goos: "js"}, {name: "wasip1", goos: "wasip1"}} { + t.Run(target.name, func(t *testing.T) { + cmd := exec.Command("go", "list", "-deps", "./cmd/sysml-wasm") + cmd.Dir = root + cmd.Env = append(os.Environ(), "GOOS="+target.goos, "GOARCH=wasm") + output, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("go list -deps for %s: %v\n%s", target.name, err, output) + } + deps := make(map[string]bool) + for _, dependency := range strings.Fields(string(output)) { + deps[dependency] = true + } + for _, dependency := range required { + if !deps[dependency] { + t.Errorf("dependency list does not contain required package %s", dependency) + } + } + for dependency := range deps { + if forbiddenCombinedDependency(module, dependency) { + t.Errorf("forbidden dependency in %s build: %s", target.name, dependency) + } + } + }) + } +} + +func forbiddenCombinedDependency(module, dependency string) bool { + for _, prefix := range []string{ + module + "/api/", + "google.golang.org/protobuf", + "google.golang.org/grpc", + "connectrpc.com/", + module + "/internal/frontend/grpc", + module + "/internal/frontend/protoconv", + module + "/internal/frontend/stdiorpc", + module + "/internal/doc/", + } { + if strings.HasPrefix(dependency, prefix) { + return true + } + } + return false +} diff --git a/tests/wasm/testdata/combined.mjs b/tests/wasm/testdata/combined.mjs new file mode 100644 index 0000000000..9f3ac9e626 --- /dev/null +++ b/tests/wasm/testdata/combined.mjs @@ -0,0 +1,24 @@ +// The combined js host: globalThis.sysmlWasm.call(method, paramsJSON) returns +// the JSON-RPC envelope synchronously. +import fs from 'node:fs'; +import { pathToFileURL } from 'node:url'; + +const [wasmExec, binary] = process.argv.slice(2); +await import(pathToFileURL(wasmExec).href); +const go = new Go(); +const { instance } = await WebAssembly.instantiate(fs.readFileSync(binary), go.importObject); +go.run(instance); +await new Promise(r => setImmediate(r)); + +const content = 'package Demo { part def Item; }'; +const call = (method, params) => globalThis.sysmlWasm.call(method, JSON.stringify(params)); +const parseFile = globalThis.sysmlWasm.call('ParseFile', JSON.stringify({ content })); +const hash = JSON.parse(parseFile).result?.modelHash; +console.log('version', globalThis.sysmlWasm.version); +console.log('ParseFile', parseFile); +console.log('Evaluate', call('Evaluate', { modelHash: hash, expression: '1 + 1' })); +console.log('GetSymbol', call('GetSymbol', { modelHash: hash, symbolId: 'Demo::Item' })); +console.log('GetDiagnostics', call('GetDiagnostics', { modelHash: hash })); +console.log('malformed', globalThis.sysmlWasm.call('ParseFile', '{')); +console.log('afterMalformed', call('GetDiagnostics', { modelHash: hash })); +process.exit(0); diff --git a/tests/wasm/wasm_build_test.go b/tests/wasm/wasm_build_test.go index dcf6399be2..98d611ee9f 100644 --- a/tests/wasm/wasm_build_test.go +++ b/tests/wasm/wasm_build_test.go @@ -27,7 +27,7 @@ var wasmTargets = []wasmTarget{ } // commands are the commands every target builds. -var commands = []string{"sysml", "sysml-lsp", "sysml-grpc", "sysml-engine", "sysml-syntax", "sysml-core"} +var commands = []string{"sysml", "sysml-lsp", "sysml-grpc", "sysml-engine", "sysml-syntax", "sysml-core", "sysml-wasm"} // versionStamp is what -version reports after the cross-link: the value the ldflags // below set, so a link that dropped the stamps is caught by a run, not by a release. diff --git a/tests/wasm/wasm_run_test.go b/tests/wasm/wasm_run_test.go index f0eee8e135..b4e99343a0 100644 --- a/tests/wasm/wasm_run_test.go +++ b/tests/wasm/wasm_run_test.go @@ -770,6 +770,9 @@ func TestWasmRuns(t *testing.T) { // sysml-core's half: validation RPCs with symbol facts, stdio and JS host. coreSubtests(t, target, r, bins) + + // sysml-wasm combines the validation and execution JSON surfaces. + combinedSubtests(t, target, r, bins) }) } } From f378b4a5b04045082088cc2f6e86706a7f18d266 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 4 Oct 2026 00:29:45 +0000 Subject: [PATCH 2/2] fix(wasm): share model retention across the combined frontends Co-Authored-By: jason.han --- docs/reference/wasm.md | 1 + internal/frontend/combined/combined.go | 134 ++++++++++++---- internal/frontend/combined/combined_test.go | 160 ++++++++++++++++++++ internal/frontend/core/core.go | 19 ++- internal/frontend/engine/engine.go | 36 +++-- 5 files changed, 308 insertions(+), 42 deletions(-) diff --git a/docs/reference/wasm.md b/docs/reference/wasm.md index ab6b452415..5edcf0a87b 100644 --- a/docs/reference/wasm.md +++ b/docs/reference/wasm.md @@ -220,6 +220,7 @@ produce the same model hash and return the core response, including roots and di A hash from either parse method is shared by every method that takes a `modelHash`, including `GetDiagnostics`, `GetSymbol`, `Evaluate`, `Instantiate`, `ExecuteAction` and `ExecuteState`. +The module retains the 16 most recently used models; an evicted hash is evicted for every method. `GetServerInfo` returns the build version and these capabilities, in order: `type_facts`, `enum_values`, `evaluate_subject`, `symbol_attributes`, `unset_value`, `feature_values`, `inline_language`, `strict_conformance`, `parse_sources`, `complex_values`, diff --git a/internal/frontend/combined/combined.go b/internal/frontend/combined/combined.go index bfd30ed571..e3494202ae 100644 --- a/internal/frontend/combined/combined.go +++ b/internal/frontend/combined/combined.go @@ -5,6 +5,7 @@ package combined import ( + "container/list" "context" "encoding/json" "io" @@ -15,6 +16,8 @@ import ( "github.com/Open-MBEE/OpenSysML/internal/workspace/libs" ) +const maxModels = 16 + var capabilities = []string{ "type_facts", "enum_values", @@ -46,20 +49,28 @@ type Server struct { engine *engine.Engine core *core.Core version string + models *list.List + byHash map[string]*list.Element } // New builds both frontends over one frozen standard-library snapshot. func New(version string) (*Server, error) { index, src := libs.FrozenLibrary() - execution, err := engine.NewWithLibrary(index, src) + execution, err := engine.NewWithLibrary(index, src, 0) if err != nil { return nil, err } - validation, err := core.NewWithLibrary(index, src) + validation, err := core.NewWithLibrary(index, src, 0) if err != nil { return nil, err } - return &Server{engine: execution, core: validation, version: version}, nil + return &Server{ + engine: execution, + core: validation, + version: version, + models: list.New(), + byHash: make(map[string]*list.Element), + }, nil } // Call runs one combined method with protojson-shaped request parameters. @@ -70,14 +81,7 @@ func (s *Server) Call(ctx context.Context, method string, params []byte) ([]byte if err != nil { return nil, err } - engineBody, err := s.engine.Call(ctx, method, params) - if err != nil { - return nil, err - } - if err := sameModelHash(body, engineBody); err != nil { - return nil, err - } - return body, nil + return s.finishParse(ctx, body, method, params) case "ParseFile": body, err := s.core.Call(ctx, method, params) if err != nil { @@ -85,6 +89,7 @@ func (s *Server) Call(ctx context.Context, method string, params []byte) ([]byte } var req core.JParseFileRequest if err := jsonrpc.Decode(params, &req); err != nil { + s.discardUnretained(body) return nil, err } document := &engine.JSourceDocument{} @@ -100,20 +105,22 @@ func (s *Server) Call(ctx context.Context, method string, params []byte) ([]byte StrictConformance: req.StrictConformance, }) if err != nil { + s.discardUnretained(body) return nil, jsonrpc.Errorf(jsonrpc.CodeInternal, "encoding engine ParseSources request: %v", err) } - engineBody, err := s.engine.Call(ctx, "ParseSources", engineParams) - if err != nil { - return nil, err - } - if err := sameModelHash(body, engineBody); err != nil { - return nil, err - } - return body, nil + return s.finishParse(ctx, body, "ParseSources", engineParams) case "GetDiagnostics", "GetSymbol": - return s.core.Call(ctx, method, params) + body, err := s.core.Call(ctx, method, params) + if err == nil { + s.touchRequest(params) + } + return body, err case "Evaluate", "Instantiate", "ExecuteAction", "ExecuteState": - return s.engine.Call(ctx, method, params) + body, err := s.engine.Call(ctx, method, params) + if err == nil { + s.touchRequest(params) + } + return body, err case "GetServerInfo": return json.Marshal(struct { Version string `json:"version,omitempty"` @@ -125,19 +132,92 @@ func (s *Server) Call(ctx context.Context, method string, params []byte) ([]byte } } -func sameModelHash(coreBody, engineBody []byte) error { - var coreResponse, engineResponse struct { +func (s *Server) finishParse(ctx context.Context, coreBody []byte, method string, params []byte) ([]byte, error) { + engineBody, err := s.engine.Call(ctx, method, params) + if err != nil { + s.discardUnretained(coreBody, engineBody) + return nil, err + } + if err := sameModelHash(coreBody, engineBody); err != nil { + s.discardUnretained(coreBody, engineBody) + return nil, err + } + hash, err := responseModelHash(coreBody) + if err != nil { + s.discardUnretained(coreBody, engineBody) + return nil, jsonrpc.Errorf(jsonrpc.CodeInternal, "decoding core model hash: %v", err) + } + s.retain(hash) + return coreBody, nil +} + +func (s *Server) retain(hash string) { + if hash == "" { + return + } + if elem, ok := s.byHash[hash]; ok { + s.models.MoveToFront(elem) + return + } + s.byHash[hash] = s.models.PushFront(hash) + if s.models.Len() > maxModels { + oldest := s.models.Back() + evicted := oldest.Value.(string) + s.models.Remove(oldest) + delete(s.byHash, evicted) + s.core.Evict(evicted) + s.engine.Evict(evicted) + } +} + +func (s *Server) touchRequest(params []byte) { + var req struct { ModelHash string `json:"modelHash"` } - if err := json.Unmarshal(coreBody, &coreResponse); err != nil { + if json.Unmarshal(params, &req) != nil { + return + } + if elem, ok := s.byHash[req.ModelHash]; ok { + s.models.MoveToFront(elem) + } +} + +func (s *Server) discardUnretained(responses ...[]byte) { + for _, body := range responses { + hash, err := responseModelHash(body) + if err != nil || hash == "" { + continue + } + if _, retained := s.byHash[hash]; retained { + continue + } + s.core.Evict(hash) + s.engine.Evict(hash) + } +} + +func responseModelHash(body []byte) (string, error) { + var response struct { + ModelHash string `json:"modelHash"` + } + if err := json.Unmarshal(body, &response); err != nil { + return "", err + } + return response.ModelHash, nil +} + +func sameModelHash(coreBody, engineBody []byte) error { + coreHash, err := responseModelHash(coreBody) + if err != nil { return jsonrpc.Errorf(jsonrpc.CodeInternal, "decoding core model hash: %v", err) } - if err := json.Unmarshal(engineBody, &engineResponse); err != nil { + engineHash, err := responseModelHash(engineBody) + if err != nil { return jsonrpc.Errorf(jsonrpc.CodeInternal, "decoding engine model hash: %v", err) } - if coreResponse.ModelHash != engineResponse.ModelHash { + if coreHash != engineHash { return jsonrpc.Errorf(jsonrpc.CodeInternal, - "sysml-wasm model hash mismatch: core %q, engine %q", coreResponse.ModelHash, engineResponse.ModelHash) + "sysml-wasm model hash mismatch: core %q, engine %q", coreHash, engineHash) } return nil } diff --git a/internal/frontend/combined/combined_test.go b/internal/frontend/combined/combined_test.go index 50d12a5929..a22c81cf98 100644 --- a/internal/frontend/combined/combined_test.go +++ b/internal/frontend/combined/combined_test.go @@ -7,6 +7,7 @@ import ( "context" "encoding/json" "errors" + "fmt" "reflect" "testing" @@ -84,3 +85,162 @@ func TestParseFileHashIsAcceptedByEvaluate(t *testing.T) { t.Errorf("Evaluate = %s, want intValue 2", evaluated) } } + +func TestCoreAccessRefreshesSharedRetention(t *testing.T) { + server := newRetentionTestServer(t) + hashes := parseRetentionModels(t, server, 16, 0) + + callRetentionMethod(t, server, "GetDiagnostics", hashes[0]) + parseRetentionModels(t, server, 1, 16) + + callRetentionMethod(t, server, "Evaluate", hashes[0]) + callRetentionMethod(t, server, "GetSymbol", hashes[0]) + assertRetentionNotFound(t, server, "Evaluate", hashes[1]) + assertRetentionNotFound(t, server, "GetSymbol", hashes[1]) +} + +func TestEngineAccessRefreshesSharedRetention(t *testing.T) { + server := newRetentionTestServer(t) + hashes := parseRetentionModels(t, server, 16, 0) + + callRetentionMethod(t, server, "Evaluate", hashes[0]) + parseRetentionModels(t, server, 1, 16) + + callRetentionMethod(t, server, "GetDiagnostics", hashes[0]) + callRetentionMethod(t, server, "GetSymbol", hashes[0]) + assertRetentionNotFound(t, server, "GetDiagnostics", hashes[1]) + assertRetentionNotFound(t, server, "Evaluate", hashes[1]) +} + +func TestReparsingRefreshesSharedRetention(t *testing.T) { + server := newRetentionTestServer(t) + hashes := parseRetentionModels(t, server, 16, 0) + if got := parseRetentionModels(t, server, 1, 0)[0]; got != hashes[0] { + t.Fatalf("reparsed hash = %q, want %q", got, hashes[0]) + } + parseRetentionModels(t, server, 1, 16) + + callRetentionMethod(t, server, "GetDiagnostics", hashes[0]) + callRetentionMethod(t, server, "Evaluate", hashes[0]) + assertRetentionNotFound(t, server, "GetDiagnostics", hashes[1]) + assertRetentionNotFound(t, server, "Evaluate", hashes[1]) +} + +func newRetentionTestServer(t *testing.T) *Server { + t.Helper() + server, err := New("combined-retention-test") + if err != nil { + t.Fatalf("New: %v", err) + } + return server +} + +func parseRetentionModels(t *testing.T, server *Server, count, start int) []string { + t.Helper() + hashes := make([]string, 0, count) + for i := start; i < start+count; i++ { + params, err := json.Marshal(struct { + Content string `json:"content"` + }{Content: fmt.Sprintf("package M%d { attribute def T; }", i)}) + if err != nil { + t.Fatalf("encoding ParseFile request: %v", err) + } + body, err := server.Call(context.Background(), "ParseFile", params) + if err != nil { + t.Fatalf("ParseFile(M%d): %v", i, err) + } + var response struct { + ModelHash string `json:"modelHash"` + } + if err := json.Unmarshal(body, &response); err != nil { + t.Fatalf("decoding ParseFile(M%d): %v\n%s", i, err, body) + } + hashes = append(hashes, response.ModelHash) + } + return hashes +} + +func callRetentionMethod(t *testing.T, server *Server, method, hash string) { + t.Helper() + var params any + switch method { + case "Evaluate": + params = struct { + ModelHash string `json:"modelHash"` + Expression string `json:"expression"` + }{ModelHash: hash, Expression: "1 + 1"} + case "GetSymbol": + params = struct { + ModelHash string `json:"modelHash"` + SymbolId string `json:"symbolId"` + }{ModelHash: hash, SymbolId: "M0::T"} + default: + params = struct { + ModelHash string `json:"modelHash"` + }{ModelHash: hash} + } + body, err := json.Marshal(params) + if err != nil { + t.Fatalf("encoding %s request: %v", method, err) + } + responseBody, err := server.Call(context.Background(), method, body) + if err != nil { + t.Fatalf("%s(%s): %v", method, hash, err) + } + switch method { + case "Evaluate": + var response struct { + Result struct { + IntValue string `json:"intValue"` + } `json:"result"` + Error string `json:"error"` + } + if err := json.Unmarshal(responseBody, &response); err != nil { + t.Fatalf("decoding Evaluate response: %v\n%s", err, responseBody) + } + if response.Error != "" || response.Result.IntValue != "2" { + t.Fatalf("Evaluate response = %s, want intValue 2", responseBody) + } + case "GetSymbol": + var response struct { + Symbol json.RawMessage `json:"symbol"` + Error string `json:"error"` + } + if err := json.Unmarshal(responseBody, &response); err != nil { + t.Fatalf("decoding GetSymbol response: %v\n%s", err, responseBody) + } + if response.Error != "" || len(response.Symbol) == 0 { + t.Fatalf("GetSymbol response = %s, want a symbol", responseBody) + } + } +} + +func assertRetentionNotFound(t *testing.T, server *Server, method, hash string) { + t.Helper() + var params any + switch method { + case "Evaluate": + params = struct { + ModelHash string `json:"modelHash"` + Expression string `json:"expression"` + }{ModelHash: hash, Expression: "1 + 1"} + case "GetSymbol": + params = struct { + ModelHash string `json:"modelHash"` + SymbolId string `json:"symbolId"` + }{ModelHash: hash, SymbolId: "M0::T"} + default: + params = struct { + ModelHash string `json:"modelHash"` + }{ModelHash: hash} + } + body, err := json.Marshal(params) + if err != nil { + t.Fatalf("encoding %s request: %v", method, err) + } + _, err = server.Call(context.Background(), method, body) + var callErr *jsonrpc.Error + if !errors.As(err, &callErr) || callErr.Code != jsonrpc.CodeNotFound { + t.Errorf("%s(%s) error = %v, want NotFound", method, hash, err) + } +} diff --git a/internal/frontend/core/core.go b/internal/frontend/core/core.go index d5add43164..3ba7210b15 100644 --- a/internal/frontend/core/core.go +++ b/internal/frontend/core/core.go @@ -65,21 +65,24 @@ type Core struct { library libs.Source models *list.List byHash map[string]*list.Element + maxModels int } // New builds a core server over the frozen standard-library snapshot. func New() (*Core, error) { index, library := libs.FrozenLibrary() - return NewWithLibrary(index, library) + return NewWithLibrary(index, library, maxCachedModels) } // NewWithLibrary builds a core server over a shared standard-library snapshot. -func NewWithLibrary(index *symbols.Index, library libs.Source) (*Core, error) { +// A nonpositive maxModels disables automatic eviction. +func NewWithLibrary(index *symbols.Index, library libs.Source, maxModels int) (*Core, error) { return &Core{ libraryIndex: index, library: library, models: list.New(), byHash: make(map[string]*list.Element), + maxModels: maxModels, }, nil } @@ -295,12 +298,22 @@ func (c *Core) get(hash string) (*cachedModel, bool) { return elem.Value.(*cacheEntry).model, true } +// Evict removes a cached model; absent hashes are ignored. +func (c *Core) Evict(hash string) { + elem, ok := c.byHash[hash] + if !ok { + return + } + c.models.Remove(elem) + delete(c.byHash, hash) +} + func (c *Core) add(hash string, model *cachedModel) *cachedModel { if elem, ok := c.byHash[hash]; ok { c.models.MoveToFront(elem) return elem.Value.(*cacheEntry).model } - if c.models.Len() >= maxCachedModels { + if c.maxModels > 0 && c.models.Len() >= c.maxModels { if oldest := c.models.Back(); oldest != nil { c.models.Remove(oldest) delete(c.byHash, oldest.Value.(*cacheEntry).hash) diff --git a/internal/frontend/engine/engine.go b/internal/frontend/engine/engine.go index 821b6c920d..8ed619c239 100644 --- a/internal/frontend/engine/engine.go +++ b/internal/frontend/engine/engine.go @@ -164,31 +164,33 @@ type Engine struct { libIndex *symbols.Index libSrc libs.Source budgets runtime.Budgets - // models is the bounded store of parsed models keyed by content hash, - // oldest at the back. - models *list.List - byHash map[string]*list.Element + // models stores parsed models keyed by content hash, oldest at the back. + models *list.List + byHash map[string]*list.Element + maxModels int } // New builds an engine over the frozen standard library snapshot, under the // runtime budgets a default sysml-grpc NewService runs with. func New() (*Engine, error) { index, src := libs.FrozenLibrary() - return NewWithLibrary(index, src) + return NewWithLibrary(index, src, maxCachedModels) } // NewWithLibrary builds an engine over a shared standard library snapshot. -func NewWithLibrary(index *symbols.Index, src libs.Source) (*Engine, error) { +// A nonpositive maxModels disables automatic eviction. +func NewWithLibrary(index *symbols.Index, src libs.Source, maxModels int) (*Engine, error) { budgets, err := runtime.BudgetsFromEnv() if err != nil { return nil, err } return &Engine{ - libIndex: index, - libSrc: src, - budgets: budgets, - models: list.New(), - byHash: make(map[string]*list.Element), + libIndex: index, + libSrc: src, + budgets: budgets, + models: list.New(), + byHash: make(map[string]*list.Element), + maxModels: maxModels, }, nil } @@ -202,6 +204,16 @@ func (e *Engine) get(hash string) (*cachedModel, bool) { return elem.Value.(*cacheEntry).model, true } +// Evict removes a cached model; absent hashes are ignored. +func (e *Engine) Evict(hash string) { + elem, ok := e.byHash[hash] + if !ok { + return + } + e.models.Remove(elem) + delete(e.byHash, hash) +} + // add caches model under hash, evicting the oldest model at the bound. func (e *Engine) add(hash string, model *cachedModel) { if elem, ok := e.byHash[hash]; ok { @@ -209,7 +221,7 @@ func (e *Engine) add(hash string, model *cachedModel) { elem.Value.(*cacheEntry).model = model return } - if e.models.Len() >= maxCachedModels { + if e.maxModels > 0 && e.models.Len() >= e.maxModels { if oldest := e.models.Back(); oldest != nil { e.models.Remove(oldest) delete(e.byHash, oldest.Value.(*cacheEntry).hash)