diff --git a/.circleci/config.yml b/.circleci/config.yml index 3219084e3..f8c5dff14 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -191,8 +191,8 @@ jobs: name: Lint the protobuf schema command: make proto-lint - # Baseline is an ancestor of HEAD, never live `origin/main` (a merge landing meanwhile - # would read as deletions): the first parent on main, the previous release on a `v*` tag. + # Baseline is an ancestor of HEAD, never a live branch tip (a merge landing meanwhile would + # read as deletions): the first parent on main or develop, the previous release on a `v*` tag. - run: name: Check the protobuf schema for breaking changes command: | @@ -1510,26 +1510,26 @@ jobs: workflows: version: 2 - # Build and test every merge into main; pull requests run .github/workflows/pr.yml instead. + # Build and test every merge into main or develop; pull requests run .github/workflows/pr.yml instead. # The Go suite is the four `Go …` jobs, and everything downstream requires all of them. build-test: jobs: - go-static: name: Go static checks - filters: &main-only + filters: &integration-branches branches: - only: main + only: [main, develop] tags: ignore: /.*/ - go-race-test: name: Go race tests - filters: *main-only + filters: *integration-branches - go-coverage: name: Go coverage profile - filters: *main-only + filters: *integration-branches - go-gates: name: Go gates and binaries - filters: *main-only + filters: *integration-branches - python-test: name: Python client tests requires: &go-suite @@ -1537,19 +1537,19 @@ workflows: - Go race tests - Go coverage profile - Go gates and binaries - filters: *main-only + filters: *integration-branches - rust-test: name: Rust client tests requires: *go-suite - filters: *main-only + filters: *integration-branches - java-test: name: Java client tests requires: *go-suite - filters: *main-only + filters: *integration-branches - node-test: name: Node client tests requires: *go-suite - filters: *main-only + filters: *integration-branches - scan: name: SonarCloud scan # Matched exactly, like PyPI below; this context holds SONAR_TOKEN. @@ -1566,7 +1566,7 @@ workflows: - Python client tests - Java client tests - Node client tests - filters: *main-only + filters: *integration-branches # Build and release on tags. The suite runs here too: a tag can point at any # commit, so a release is only published from a revision proven green. diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index a5185c4b1..c3682d53e 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -1,7 +1,7 @@ name: PR -# The only CI a pull request runs. Pushes to main and tags are covered by the -# CircleCI config; this workflow mirrors its build-test workflow, not release. +# The only CI a pull request runs. Pushes to main and develop, and tags, are covered +# by the CircleCI config; this workflow mirrors its build-test workflow, not release. on: pull_request: diff --git a/AGENTS.md b/AGENTS.md index 851b132b5..862536c4d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,7 +13,7 @@ It provides a hand-written lexer/parser, semantic engine, execution runtime, LSP 1. **Correctness over expedience.** No shortcuts, no stubs left behind, no lossy conversions. If a proper fix is large, do it properly or stop and flag it. - **For features specifically: do not minimize code changes or dodge complexity.** Implement the feature fully and correctly even if it touches many files, adds new types, or requires refactoring. Completeness beats diff size. See §8. 2. **Root-cause first.** Before editing, confirm *why* something fails (read the code, add a temporary debug print, write a focused test). Then make the minimal correct change. -3. **Never regress.** `main` is green. Any test passing on `main` must still pass on your branch. Diff against `main` if unsure: `git stash && git checkout main && go test ./... ; git checkout - && git stash pop`. +3. **Never regress.** `develop` is green. Any test passing on `develop` must still pass on your branch. Diff against `develop` if unsure: `git stash && git checkout develop && go test ./... ; git checkout - && git stash pop`. 4. **Respect the architecture invariants** (see §4). The AST is immutable; semantics live in side tables; execution consumes lowered IR — do not bypass these. 5. **Tests are the contract.** Existing tests encode intended behavior (including *when* and *where* errors surface). Make code satisfy tests, not the reverse — unless the test is provably wrong, in which case explain before changing it. 6. **Leave no dead code.** Remove superseded helpers/structs. Run `go vet ./...` to catch it. @@ -139,7 +139,7 @@ Then update `docs/project/spec-compliance.md` mapping: semantic rule → impleme ## 6. Development Workflow -1. **Understand first.** Grep/read the relevant package and its tests. Diff the branch against `main` to see what changed and why. +1. **Understand first.** Grep/read the relevant package and its tests. Diff the branch against `develop` to see what changed and why. 2. **Reproduce.** Run the failing test(s) and read the exact error before changing anything. 3. **Locate the root cause** in the correct layer (lexer vs parser vs lower vs runtime). Bugs in specialized layers are often upstream of where they surface. 4. **Implement the correct fix.** For bug fixes, keep edits minimal and scoped. For features, implement completely (see §8) — "minimal" means *no unrelated changes*, never *under-built*. Match existing style; keep imports at the top. diff --git a/CHANGELOG.md b/CHANGELOG.md index 8e100730b..7febd89b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -211,6 +211,8 @@ release is described in [docs/project/releasing.md](docs/project/releasing.md). - **A verification case runs and reports the verdict of its body.** `sysml -analysis`, `%analysis` and the `RunAnalysis` RPC accept a `verification def` or `verification` usage and run it the way they run an analysis case — the same lowering, subject and input binding, and step execution. The `VerdictKind` the body produced is reported: `pass` or `fail` as the library's own `VerificationCases::PassIf` calculation computes it, a `VerdictKind` literal the body binds as it stands, `inconclusive` for a body that produced no verdict value, and `error`, carrying the message, for a body whose run failed. - **The body verdict is reported beside requirement satisfaction, not instead of it.** `-requirement`, `-satisfy`, `%requirement`, `%satisfy` and the `VerifyRequirement`/`VerifySatisfaction` RPCs add one line per verification case verifying the requirement; what the requirement engine decided, and the exit status, are unchanged. A case performed as a step of another is reported on its own, marked as a subcase, since the library states no roll-up. Over gRPC the verdicts are added fields (`verification_verdicts` on `VerifyRequirementResponse`, `VerifySatisfactionResponse` and `RunAnalysisResponse`), advertised as the `verification_verdicts` capability, and `sysml -json` reports them under `verifications`. Each carries the `requirement_id` it was reported for, matching the `requirement_id` a `satisfy` verdict carries, so a satisfaction response covering several requirements is read per requirement. The Go and Python clients report the verdicts as `Verifications`/`verifications`, giving each satisfaction verdict the cases of its own requirement. +- **A worked walkthrough of analysis cases, `examples/analysis-demo`.** One lander model asked every way the tool answers: an analysis whose action steps feed each other and whose objective is a requirement, run bound, with arguments and on an object; a verification case whose body verdict is reported beside its objective; a parameter sweep and a seeded sample; two trade studies choosing among three landers, one swept over its cost parameter; and an action and a state machine due at the same instant of the shared clock, run under the default and `declared` scheduling policies and under `explore`. Each command is shown with its output and what to read in it, with `-trace` and `-json`, the REPL forms and the same questions asked through the Python client in `lander_demo.py`. + ### Changed - **What may differ from 0.6.0.** A model that mixes actions and state machines can take another @@ -244,6 +246,11 @@ release is described in [docs/project/releasing.md](docs/project/releasing.md). - **The SonarCloud findings outside cognitive complexity are cleared again.** Duplicated literals are named constants, same-typed parameters share a declaration, `encodeMember` takes its member head as a struct, marker methods state their contract, unnecessary locals are inlined, the release-gate script reports errors on stderr, the MSI script names its positional parameters, the Java transport catches connect timeouts in their own block, and the Java and Python tests hold one call per exception assertion. The exhaustive switches of the AST codec and the planners' error kinds, and the sealed code-generation IR, are documented exclusions. No behavior changes. +- Development moved to a `develop` integration branch; `main` now carries releases only. + Feature and fix pull requests target `develop`, releases reach `main` through + `release/x.y.z` pull requests and are tagged there, and CircleCI builds and tests both + branches. `make proto-breaking` compares against `origin/develop` by default. + ### Fixed - **A transition's `accept` with no `via` no longer takes a transfer addressed to a port.** An accept naming no port receives as the performer of the machine (SysML v2 §7.16.7), and a port is a sub-occurrence of its part, not the part, so `send new Ping() to alpha.inPort` — or a send routed to `inPort` over a connector — is now taken only by `accept Ping via inPort`; a via-less `accept Ping` on the same state is not enabled by it and no choice point is reported between the two. A transfer addressed to the part itself, `send new Ping() to alpha`, is still taken by the via-less accept and not by the `via` one. The state executor now judges every message by the same rule its dispatch check and the action executor already applied, so the two agree on what a machine can react to; call and change triggers are unaffected. @@ -285,6 +292,8 @@ release is described in [docs/project/releasing.md](docs/project/releasing.md). - **Loading a model no longer merges the library's visible member set once per declaration.** The inherited-name conflict rule looks each name up in the memoized member maps of a declaration's library bases and passed-through types instead of copying them into a fresh map per part, attribute, action and state; its diagnostics are unchanged. The OOSEM method rule memoizes a type's classification, so an attribute type shared by many features is conformance-checked once. Loading and validating a 4 000-element model is 15% faster and allocates a third fewer bytes than before; `sysml -validate` on 3 000–12 000-element models is now at or ahead of release 0.4.2. `docs/project/performance-release-0.6-vs-0.4.2.md` records the comparison, the remaining costs of the validation rules added since 0.4.2, and how to repeat it. The Apollo 11 load figure on the landing page and in `docs/internals/performance.md` is re-measured at 0.43 s: the earlier 0.37 s was taken while the model's three calculation-arity findings were still errors, before the higher validation tiers ran. +- **A long action run no longer grows the memory of a race-instrumented binary step by step.** Each step of a token used to run on a coroutine of its own so that a breakpoint or a wait on the clock inside it could pause the token, and Go's race detector keeps a coroutine's state after it ends, so a run of a million steps under `go test -race` took gigabytes and could be killed for memory. The steps of a run now share one coroutine, which only a paused step keeps for as long as it is paused; breakpoints, clock waits, `Release` and the deadlock reported for an abandoned pause behave as before. The step-budget tests of the runtime package now peak at a few hundred megabytes under the race detector rather than several gigabytes. + ## 0.6.0 — 2026-09-07 ### Added diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5266506c0..8686086e0 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -55,7 +55,7 @@ make proto # regenerate the Go, Java and Python stubs make proto-buf # Go and Java stubs only make python-proto # Python stubs only (needs grpcio-tools; PYTHON=... picks the interpreter) make proto-lint # lint the schema, as CI does -make proto-breaking # reject wire-breaking changes against main, as CI does +make proto-breaking # reject wire-breaking changes against develop, as CI does # Python gRPC bindings make python-install # install opensysml package @@ -144,14 +144,26 @@ docs: update Quick Start guide with new commands test(semantics): add conformance checking test cases ``` +## Branches + +The repository follows git-flow with two long-lived branches: + +- `develop` is the integration branch and the default branch. Every `feature/`, `fix/`, `docs/`, + `ci/` and `test/` branch is cut from `develop`, and its pull request targets `develop`. +- `main` is the release branch. It receives only `release/x.y.z` pull requests (cut from + `develop`, with the changelog fragments folded in) and `hotfix/` pull requests. Release tags — + `v*` and the client package tags — are created on `main`. After a release is tagged, `main` + is merged back into `develop` (a plain merge, no rebase) so hotfixes and the folded changelog + flow down. See [docs/project/releasing.md](docs/project/releasing.md). + ## Pull Request Process 1. **Fork** the repository -2. **Create a branch** from `main`: `git checkout -b feat/my-feature` +2. **Create a branch** from `develop`: `git checkout -b feat/my-feature` 3. **Make changes** with clear commit messages 4. **Run tests**: `go test ./...` 5. **Push** to your fork -6. **Open a Pull Request** targeting `main` +6. **Open a Pull Request** targeting `develop` ### PR Guidelines @@ -168,14 +180,17 @@ test(semantics): add conformance checking test cases ### Creating a Release 1. **Update version** (if needed in code) -2. **Tag the release:** +2. **Merge the `release/x.y.z` pull request** into `main` (see + [docs/project/releasing.md](docs/project/releasing.md)) +3. **Tag the release** on `main`: ```bash git tag -a v0.1.0 -m "Release v0.1.0: Initial public release" git push origin v0.1.0 ``` -3. **CI automatically:** +4. **CI automatically:** - Builds binaries for all platforms - Publishes to GitHub Releases +5. **Merge `main` back into `develop`** ### Release Checklist @@ -200,7 +215,8 @@ A release is a **PATCH** when everything the previous release accepted still beh result into the one the Kernel Semantic Library derives; that is compatible and is listed under *Fixed*. - No CLI flag, REPL command, RPC or wire field is removed or renamed. The protobuf - wire-compatibility check, `make proto-breaking`, passes. + wire-compatibility check passes against the previous release's schema: + `make proto-breaking BUF_BREAKING_REF=origin/main` on the release branch. New features, new flags, new capabilities and new wire fields are all patch material. @@ -243,7 +259,7 @@ is over-tested rather than untested — teach the script about it, and add a cas `.circleci/config.yml` runs after a merge and on tags, never on a pull request branch: -**On every push to `main`:** +**On every push to `main` or `develop`:** - The same suite, gates, client tests and conformance runs as the pull-request workflow, over the merged tree, plus the host binaries - SonarCloud scan, fed by the Go and client coverage reports diff --git a/Makefile b/Makefile index f464d4204..930df65ca 100644 --- a/Makefile +++ b/Makefile @@ -20,8 +20,8 @@ GO_WINRES_VERSION := v0.3.3 # buf drives all protobuf codegen; override BUF to use an already-installed binary. BUF ?= go run github.com/bufbuild/buf/cmd/buf@$(BUF_VERSION) -# Wire-compatibility baseline: the schema as it stands on the main branch. -BUF_BREAKING_REF ?= origin/main +# Wire-compatibility baseline: the schema as it stands on the develop branch. +BUF_BREAKING_REF ?= origin/develop # go-winres embeds a VERSIONINFO resource into the Windows binaries (a build # tool only; nothing of it ships). The .syso it writes carries a _windows_amd64 @@ -253,7 +253,7 @@ proto-lint: ## Lint the protobuf schema $(BUF) lint @echo "✓ Proto lint passed" -proto-breaking: ## Check the protobuf schema for wire-breaking changes against main +proto-breaking: ## Check the protobuf schema for wire-breaking changes against develop @# An archive, not the .git directory: buf would clone that, which a blobless (CI) checkout cannot serve. baseline=$$(mktemp -t proto-baseline.XXXXXX) && trap 'rm -f "$$baseline"' EXIT && \ git archive --format=tar -o "$$baseline" '$(BUF_BREAKING_REF)' api/proto && \ diff --git a/README.md b/README.md index 23e10a862..d0c494481 100644 --- a/README.md +++ b/README.md @@ -338,7 +338,7 @@ github.com/Open-MBEE/OpenSysML - **Grammar source:** OMG pilot Xtext grammars (`SysML.xtext` and `KerMLExpressions`) - **Spec compliance:** [OMG SysML v2.1 Beta 1 / KerML 1.1](https://www.omg.org/spec/SysML/2.0) (2026-07 release) - **Standard library:** 94 files from [SysML v2 Pilot Implementation 2026-07](https://github.com/Systems-Modeling/SysML-v2-Pilot-Implementation/releases/tag/2026-07), byte-identical, plus the non-normative `OpenSysML Libraries/OpenSysMLMathFunctions.kerml` extension -- **CI/CD:** GitHub Actions checks pull requests; CircleCI builds and tests `main` and publishes releases from tags +- **CI/CD:** GitHub Actions checks pull requests; CircleCI builds and tests `main` and `develop` and publishes releases from tags ## Releases diff --git a/changes/unreleased/analysis-walkthrough.added.md b/changes/unreleased/analysis-walkthrough.added.md deleted file mode 100644 index 9651c8f2b..000000000 --- a/changes/unreleased/analysis-walkthrough.added.md +++ /dev/null @@ -1 +0,0 @@ -- **A worked walkthrough of analysis cases, `examples/analysis-demo`.** One lander model asked every way the tool answers: an analysis whose action steps feed each other and whose objective is a requirement, run bound, with arguments and on an object; a verification case whose body verdict is reported beside its objective; a parameter sweep and a seeded sample; two trade studies choosing among three landers, one swept over its cost parameter; and an action and a state machine due at the same instant of the shared clock, run under the default and `declared` scheduling policies and under `explore`. Each command is shown with its output and what to read in it, with `-trace` and `-json`, the REPL forms and the same questions asked through the Python client in `lander_demo.py`. diff --git a/docs/project/releasing.md b/docs/project/releasing.md index 71b366c61..f1d091442 100644 --- a/docs/project/releasing.md +++ b/docs/project/releasing.md @@ -72,6 +72,47 @@ Then check the release-facing text: and no compliance row claims more than the implementation does. Count first-level subtests: a case that registers sub-subtests, like `variant_connection_per_owner`, otherwise counts twice. +## The release branch + +Day-to-day work merges into `develop`; `main` carries releases only (see +[CONTRIBUTING.md § Branches](../../CONTRIBUTING.md#branches)). A release is a +branch that moves the integration state onto `main`: + +1. Cut `release/x.y.z` from `develop`: + + ```bash + git checkout develop && git pull + git checkout -b release/0.0.5 + ``` + +2. Fold the changelog fragments on that branch — `python3 scripts/changelog.py release 0.0.5`, + as [Before tagging](#before-tagging) describes — and commit `CHANGELOG.md` together with the + deleted fragments. Anything else the release needs (a version string in code, a doc that + names the version) lands here too; a feature does not. Check the wire compatibility + against the released schema, not the branch's own source: + `make proto-breaking BUF_BREAKING_REF=origin/main` (the default baseline is + `origin/develop`; the pull-request workflow uses the base branch, so the PR to `main` + makes the same comparison). + +3. Open a pull request from `release/x.y.z` to `main` and merge it once the + pull-request workflow is green. Merging into `main` runs CircleCI's + `build-test` workflow over the merged tree. + +4. Tag `main` as [Tagging](#tagging) describes. + +5. Merge `main` back into `develop` — a plain merge, no rebase — so the folded + changelog, and any hotfix that landed on `main` in the meantime, flow down: + + ```bash + git checkout develop && git pull + git merge main + git push origin develop + ``` + +A `hotfix/` branch follows the same path from `main`: cut from `main`, pull +request to `main` (`make proto-breaking BUF_BREAKING_REF=origin/main` locally, as above), +tag, merge back into `develop`. + ## Tagging The tag is the version: CircleCI passes `CIRCLE_TAG` to the build as @@ -83,12 +124,11 @@ git tag -a v0.0.5 -m "v0.0.5" git push origin v0.0.5 ``` -The tag belongs on the repository the releases live on. v0.0.1–v0.0.7 are -releases of `Open-MBEE/OpenSysML`, while development happens on -`JPL-Devin/OpenSysML`, which has no tags at all — so cutting a release means -promoting `main` upstream first (v0.0.4 came through Open-MBEE PR #47) and -tagging there. Tagging the development repository would build a release nobody -consumes. +The tag belongs on `Open-MBEE/OpenSysML`, the repository the releases live on +and where development happens: every release from v0.0.1 on is tagged on its +`main`. The clients resolve releases from that repository +(`DEFAULT_GITHUB_REPO` in `clients/python/opensysml/binary.py`), so a tag pushed +to a fork builds a release nobody consumes. Tags are matched by `/^v.*/` in `.circleci/config.yml`. A tag on a commit that fails the suite fails the release workflow before anything is published. diff --git a/internal/core/runtime/action_body_run.go b/internal/core/runtime/action_body_run.go index b957c9be0..25e088239 100644 --- a/internal/core/runtime/action_body_run.go +++ b/internal/core/runtime/action_body_run.go @@ -9,13 +9,13 @@ import ( "github.com/Open-MBEE/OpenSysML/internal/core/ast" ) -// bodyRun is the work of one token's step run as a coroutine: a breakpoint met +// bodyRun is the work of one token's step run on a body coroutine: a breakpoint met // inside it, or a wait on the clock, pauses the token there until stepped again. type bodyRun struct { - // next resumes the work, yielding why it paused next, or false once the work - // has ended with err; stop ends paused work for good. - next func() (bodyPause, bool) - stop func() + // co is the coroutine the work runs on, which a pause keeps until the work + // has ended with err or was stopped for good. + co *bodyCoroutine + work func() error err error after func(tokenIdx int) error // yield pauses the work from inside; runDepth and actionDepth are the nesting @@ -28,15 +28,29 @@ type bodyRun struct { } // bodyPause is why a body run paused: at the named breakpoint, or on the clock -// for a wait of a flow it runs or of the executor (held) it performs an action with. +// for a wait of a flow it runs or of the executor (held) it performs an action with; +// ended reports the work done instead. type bodyPause struct { breakpoint string onClock bool held clockWaiter + ended bool } -// runPausable runs work for the token at tokenIdx, then after with the token's -// index by then; the work is a coroutine unless a run on the stack is pausable already. +// bodyCoroutine runs the work of token steps one after another, so the steps of a +// run share one coroutine and only a pause, which keeps it, has the next step make another. +type bodyCoroutine struct { + // next resumes the work, yielding why it paused or that it ended, or false once + // the coroutine was stopped; stop ends it, and any work paused on it, for good. + next func() (bodyPause, bool) + stop func() + // run is the work the coroutine is doing or has paused, nil while idle. + run *bodyRun +} + +// runPausable runs work for the token at tokenIdx, then after with the token's index +// by then, inline when a run on the stack is pausable already. The steps of a run +// share one body coroutine, kept only by a pause, as the race detector never frees one. func (e *ActionExecutor) runPausable(tokenIdx int, work func() error, after func(tokenIdx int) error) error { if e.ctx.pausable != nil { if err := work(); err != nil { @@ -44,17 +58,56 @@ func (e *ActionExecutor) runPausable(tokenIdx int, work func() error, after func } return after(tokenIdx) } - run := &bodyRun{after: after} - run.next, run.stop = iter.Pull(func(yield func(bodyPause) bool) { - run.yield = yield - e.ctx.pausable = run - defer func() { e.ctx.pausable = nil }() - run.err = work() - }) + run := &bodyRun{co: e.ctx.takeBodyCoroutine(), work: work, after: after} e.tokens[tokenIdx].body = run return e.resumeBody(tokenIdx) } +// takeBodyCoroutine takes the idle body coroutine for a step's work, making one when none is. +func (ctx *Context) takeBodyCoroutine() *bodyCoroutine { + if co := ctx.idleBody; co != nil { + ctx.idleBody = nil + return co + } + ctx.bodyCoroutinesMade++ + co := &bodyCoroutine{} + co.next, co.stop = iter.Pull(func(yield func(bodyPause) bool) { + for { + co.run.perform(ctx, yield) + if !yield(bodyPause{ended: true}) { + return + } + } + }) + return co +} + +// perform does the run's work on the coroutine resuming it, pausable through yield meanwhile. +func (run *bodyRun) perform(ctx *Context, yield func(bodyPause) bool) { + run.yield = yield + ctx.pausable = run + defer func() { ctx.pausable = nil }() + run.err = run.work() +} + +// keepBodyCoroutine keeps co, whose work ended, idle for the next step; a second +// idle one is ended instead. +func (ctx *Context) keepBodyCoroutine(co *bodyCoroutine) { + if ctx.idleBody != nil { + co.stop() + return + } + ctx.idleBody = co +} + +// endIdleBodyCoroutine ends the idle body coroutine, if any, as the outermost run leaves. +func (ctx *Context) endIdleBodyCoroutine() { + if co := ctx.idleBody; co != nil { + ctx.idleBody = nil + co.stop() + } +} + // Release ends the run for good: the work of every token a breakpoint left // paused is ended, so an executor abandoned mid-run holds no suspended run, the // clock drives it no further, and a later Step or RunToCompletion returns @@ -80,7 +133,7 @@ func (e *ActionExecutor) endPausedBodies() { // end ends the paused work for good, unwinding it on the nesting the context has now. func (run *bodyRun) end(ctx *Context) { run.runDepth, run.actionDepth = ctx.runDepth, ctx.actionDepth - run.stop() + run.co.stop() } // pausedTokens returns the IDs of the tokens whose work a breakpoint paused, the @@ -117,7 +170,10 @@ func (e *ActionExecutor) tokenIndex(id int64) int { func (e *ActionExecutor) resumeBody(tokenIdx int) error { run := e.tokens[tokenIdx].body run.runDepth, run.actionDepth = e.ctx.runDepth, e.ctx.actionDepth - if pause, paused := run.next(); paused { + co := run.co + co.run = run + pause, alive := co.next() + if alive && !pause.ended { e.pauses++ run.pausedAt = e.pauses run.paused = pause @@ -128,6 +184,10 @@ func (e *ActionExecutor) resumeBody(tokenIdx int) error { return nil } e.tokens[tokenIdx].body = nil + co.run = nil + if alive { + e.ctx.keepBodyCoroutine(co) + } if run.err != nil { return run.err } diff --git a/internal/core/runtime/action_body_run_test.go b/internal/core/runtime/action_body_run_test.go new file mode 100644 index 000000000..0390e0741 --- /dev/null +++ b/internal/core/runtime/action_body_run_test.go @@ -0,0 +1,138 @@ +package runtime + +import ( + "testing" + + "github.com/Open-MBEE/OpenSysML/internal/core/ast" +) + +// The steps of a run whose work never pauses all run on one body coroutine, which +// the run ends when it leaves, so a long run makes no coroutine per step. +func TestStepsOfARunShareOneBodyCoroutine(t *testing.T) { + ctx, sym := loadAction(t, `package test { + private import ScalarValues::*; + action counter { + attribute n : Integer = 0; + first start; + merge again; + action bump { assign n := n + 1; } + done; + succession first start then again; + succession first again then bump; + succession first bump then check; + decide check; + if n < 200 then again; + if n >= 200 then done; + } + }`, "counter") + exec, err := ctx.CreateActionExecutor(sym) + if err != nil { + t.Fatalf("CreateActionExecutor: %v", err) + } + if err := exec.RunToCompletion(); err != nil { + t.Fatalf("RunToCompletion: %v", err) + } + if n := exec.Results()["n"]; n.Const.Int != 200 { + t.Fatalf("n = %v, want 200 steps of bump", n) + } + if made := ctx.bodyCoroutinesMade; made != 1 { + t.Errorf("the run made %d body coroutines, want 1 shared by its steps", made) + } + if ctx.idleBody != nil { + t.Error("a body coroutine is still kept after the run left") + } + for _, token := range exec.tokens { + if token.body != nil { + t.Errorf("token %d holds work after the run completed", token.ID) + } + } +} + +// A step a breakpoint pauses keeps its coroutine for as long as it is paused, and +// the steps resuming it pause it again at the breakpoint rather than run through. +func TestAPausedStepKeepsItsBodyCoroutine(t *testing.T) { + exec := blockDebugExecutor(t) + exec.SetBreakpoint("add") + for pass := int64(0); pass < 3; pass++ { + if err := exec.RunToCompletion(); err != nil { + t.Fatalf("pass %d: RunToCompletion: %v", pass, err) + } + if got := exec.PausedAt(); got != "add" { + t.Fatalf("pass %d: PausedAt() = %q, want add", pass, got) + } + var paused *bodyRun + for _, token := range exec.tokens { + if token.body != nil { + paused = token.body + } + } + if paused == nil { + t.Fatalf("pass %d: no token holds the paused work", pass) + } + if paused.co == nil || paused.co.run != paused { + t.Errorf("pass %d: the paused work does not hold its coroutine", pass) + } + if exec.ctx.idleBody != nil { + t.Errorf("pass %d: a body coroutine is kept idle while the run is paused", pass) + } + } + if err := exec.RunToCompletion(); err != nil { + t.Fatalf("final run: %v", err) + } + if got := exec.State(); got != StateCompleted { + t.Errorf("State() = %v, want %v", got, StateCompleted) + } + if n := exec.Results()["total"]; n.Const.Int != 13 { + t.Errorf("total = %v, want 13", n) + } + if made := exec.ctx.bodyCoroutinesMade; made != 1 { + t.Errorf("the run made %d body coroutines, want the one its steps share", made) + } +} + +// A step whose body waits on the clock pauses on its coroutine until the clock +// reaches the wait, and the resumed step goes on from the wait. +func TestAStepWaitingOnTheClockPausesItsBodyCoroutine(t *testing.T) { + idx, _, ctx := buildRuntimeWithLibraries(t, "", parseAndBuild(t, nestedWaitModel)) + sym := findSymbolByName(idx.DocumentRoot(""), "outer", ast.DefAction) + if sym == nil { + t.Fatal("action outer not found") + } + exec, err := ctx.CreateActionExecutor(sym) + if err != nil { + t.Fatalf("CreateActionExecutor: %v", err) + } + if err := exec.RunToQuiescence(); err != nil { + t.Fatalf("RunToQuiescence: %v", err) + } + if exec.State() != StateWaiting { + t.Fatalf("State() = %v, want Waiting on the nested flow's clock wait", exec.State()) + } + var waiting *bodyRun + for _, token := range exec.tokens { + if token.pausedOnClock() { + waiting = token.body + } + } + if waiting == nil { + t.Fatal("no token is paused on the clock") + } + if waiting.co == nil || waiting.co.run != waiting { + t.Error("the work paused on the clock does not hold its coroutine") + } + if _, err := ctx.Advance(5); err != nil { + t.Fatalf("Advance(5): %v", err) + } + if got := exec.Results()["n"]; got.Kind != ValConst || got.Const.Int != 1 { + t.Errorf("n = %v once the clock reached 5; want 1", got) + } + if exec.State() != StateCompleted { + t.Errorf("State() = %v, want Completed", exec.State()) + } + if made := ctx.bodyCoroutinesMade; made != 1 { + t.Errorf("the run made %d body coroutines, want 1", made) + } + if ctx.idleBody != nil { + t.Error("a body coroutine is still kept after the run completed") + } +} diff --git a/internal/core/runtime/context.go b/internal/core/runtime/context.go index dc847f70e..f45892c4f 100644 --- a/internal/core/runtime/context.go +++ b/internal/core/runtime/context.go @@ -187,6 +187,10 @@ type Context struct { // pausable is the body run on the stack a breakpoint or a wait on the clock // pauses (action_body_run.go), nil while none is. pausable *bodyRun + // idleBody is the body coroutine no step's work is on, kept for the next step + // until the outermost run leaves; bodyCoroutinesMade counts the ones made. + idleBody *bodyCoroutine + bodyCoroutinesMade int // calcDepth is the number of calc invocations currently on the stack, which // maxCalcDepth bounds, so a recursion evaluates while it stays within it. @@ -620,7 +624,16 @@ func (ctx *Context) enterRun(state *runState) func() { ctx.run = state } ctx.runDepth++ - return func() { ctx.runDepth-- } + return ctx.leaveRun +} + +// leaveRun ends one call of the run under way; the outermost leaving ends the body +// coroutine kept idle for its steps. +func (ctx *Context) leaveRun() { + ctx.runDepth-- + if ctx.runDepth == 0 { + ctx.endIdleBodyCoroutine() + } } // beginRun starts a run and returns the function that ends it: a top-level run @@ -703,7 +716,7 @@ func (ctx *Context) beginProbe() func() { ids.release(nextID) } ctx.probes-- - ctx.runDepth-- + ctx.leaveRun() run.steps, run.elements, ctx.trace, ctx.stepWrites = steps, elements, trace, writes } } diff --git a/internal/core/runtime/debug_api_test.go b/internal/core/runtime/debug_api_test.go index e3b36a56c..92969dd9a 100644 --- a/internal/core/runtime/debug_api_test.go +++ b/internal/core/runtime/debug_api_test.go @@ -728,7 +728,7 @@ func TestReleaseEndsAPausedBody(t *testing.T) { } exec.Release() - if _, paused := run.next(); paused { + if _, paused := run.co.next(); paused { t.Error("the paused work still yields after Release") } if !errors.Is(run.err, ErrActionDeadlock) { @@ -767,7 +767,7 @@ func TestBudgetFailureEndsAPausedBody(t *testing.T) { if err := exec.RunToCompletion(); !errors.Is(err, ErrActionStepLimitExceeded) { t.Fatalf("resume = %v, want ErrActionStepLimitExceeded", err) } - if _, paused := run.next(); paused { + if _, paused := run.co.next(); paused { t.Error("the paused work still yields after the budget failure") } for _, token := range exec.tokens { @@ -838,7 +838,7 @@ func TestFailedStepEndsPausedBodies(t *testing.T) { if err := exec.RunToCompletion(); !errors.Is(err, ErrDivisionByZero) { t.Fatalf("RunToCompletion = %v, want ErrDivisionByZero from the sibling", err) } - if _, paused := run.next(); paused { + if _, paused := run.co.next(); paused { t.Error("the paused work still yields after the run failed") } }