Skip to content

Commit 1672291

Browse files
committed
Add EPaxos visualizer and Pages deployment
1 parent 4dd453f commit 1672291

18 files changed

Lines changed: 5125 additions & 0 deletions

File tree

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
name: visualizer pages
2+
3+
on:
4+
push:
5+
branches: [main]
6+
pull_request:
7+
branches: [main]
8+
workflow_dispatch:
9+
10+
concurrency:
11+
group: pages-${{ github.event_name == 'pull_request' && github.ref || 'deploy' }}
12+
cancel-in-progress: false
13+
14+
jobs:
15+
build:
16+
name: Build visualizer
17+
runs-on: ubuntu-latest
18+
timeout-minutes: 10
19+
permissions:
20+
contents: read
21+
env:
22+
GO_VERSION: '1.26.5'
23+
steps:
24+
- name: Check out repository
25+
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
26+
27+
- name: Set up Go
28+
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
29+
with:
30+
go-version: ${{ env.GO_VERSION }}
31+
cache: true
32+
33+
- name: Test visualizer simulator
34+
run: go test ./visualizer/internal/sim -count=1
35+
36+
- name: Verify reproducible Wasm build
37+
run: tests/visualizer_build.sh
38+
39+
- name: Build Pages artifact
40+
run: ./visualizer/build.sh visualizer/dist
41+
42+
- name: Upload Pages artifact
43+
uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0
44+
with:
45+
path: visualizer/dist
46+
47+
deploy:
48+
name: Deploy visualizer
49+
if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main'
50+
needs: build
51+
runs-on: ubuntu-latest
52+
timeout-minutes: 5
53+
permissions:
54+
contents: read
55+
pages: write
56+
id-token: write
57+
environment:
58+
name: github-pages
59+
url: ${{ steps.deployment.outputs.page_url }}
60+
steps:
61+
- name: Configure GitHub Pages
62+
uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6.0.0
63+
64+
- name: Deploy GitHub Pages
65+
id: deployment
66+
uses: actions/deploy-pages@cd2ce8fcbc39b97be8ca5fce6e763baed58fa128 # v5.0.0

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,3 +41,4 @@ kvnode-data/
4141
# .idea/
4242
# .vscode/
4343
.formal-closure-evidence/
44+
visualizer/dist/

tests/ci.sh

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
55
cd "$ROOT"
66

77
bash tests/toolchain_audit.sh
8+
bash tests/visualizer_build.sh
89
bash tests/go_coverage.sh
910
bash tests/tla_model_check_fast.sh
1011
bash tests/trace_refinement_check.sh

tests/visualizer_build.sh

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
#!/usr/bin/env bash
2+
set -euo pipefail
3+
4+
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
5+
OUTPUT="$(mktemp -d)"
6+
trap 'rm -rf "$OUTPUT"' EXIT
7+
8+
cd "$ROOT"
9+
./visualizer/build.sh "$OUTPUT"
10+
11+
ASSETS=(index.html styles.css app.js wasm.js view.js favicon.svg wasm_exec.js epaxos.wasm)
12+
for asset in "${ASSETS[@]}"; do
13+
if [[ ! -s "$OUTPUT/$asset" ]]; then
14+
echo "visualizer build did not produce nonempty $asset" >&2
15+
exit 1
16+
fi
17+
done
18+
19+
GOOS=js GOARCH=wasm go vet -tags=purego ./visualizer/cmd/wasm

visualizer/build.sh

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
#!/usr/bin/env bash
2+
set -euo pipefail
3+
4+
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
5+
OUTPUT="${1:-$ROOT/visualizer/dist}"
6+
SHIM="$(go env GOROOT)/lib/wasm/wasm_exec.js"
7+
ASSETS=(index.html styles.css app.js wasm.js view.js favicon.svg)
8+
9+
if [[ ! -f "$SHIM" ]]; then
10+
echo "Go's WebAssembly runtime shim was not found." >&2
11+
exit 1
12+
fi
13+
14+
mkdir -p "$OUTPUT"
15+
for asset in "${ASSETS[@]}"; do
16+
rm -f "$OUTPUT/$asset"
17+
cp "$ROOT/visualizer/web/$asset" "$OUTPUT/$asset"
18+
done
19+
rm -f "$OUTPUT/wasm_exec.js" "$OUTPUT/epaxos.wasm"
20+
cp "$SHIM" "$OUTPUT/wasm_exec.js"
21+
chmod 0644 "$OUTPUT/wasm_exec.js"
22+
GOOS=js GOARCH=wasm go build -tags=purego -trimpath -ldflags='-s -w' -o "$OUTPUT/epaxos.wasm" ./visualizer/cmd/wasm

visualizer/cmd/wasm/main.go

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
//go:build js && wasm
2+
3+
package main
4+
5+
import (
6+
"encoding/json"
7+
"errors"
8+
"io"
9+
"strings"
10+
"syscall/js"
11+
12+
"gosuda.org/moreconsensus/visualizer/internal/sim"
13+
)
14+
15+
type request struct {
16+
Op string `json:"op"`
17+
Scenario string `json:"scenario"`
18+
Size int `json:"size"`
19+
Index int `json:"index"`
20+
Action sim.Action `json:"action"`
21+
}
22+
23+
type response struct {
24+
OK bool `json:"ok"`
25+
Scenarios []sim.ScenarioMeta `json:"scenarios,omitempty"`
26+
Trace *sim.ScenarioTrace `json:"trace,omitempty"`
27+
Frame *sim.Frame `json:"frame,omitempty"`
28+
CanBack *bool `json:"canBack,omitempty"`
29+
CanForward *bool `json:"canForward,omitempty"`
30+
Error *errorResponse `json:"error,omitempty"`
31+
}
32+
33+
type errorResponse struct {
34+
Code string `json:"code"`
35+
Message string `json:"message"`
36+
}
37+
38+
type bridge struct {
39+
lab *sim.Session
40+
labCursor int
41+
labLength int
42+
}
43+
44+
var dispatchFunction js.Func
45+
46+
func main() {
47+
state := &bridge{}
48+
dispatchFunction = js.FuncOf(state.dispatch)
49+
js.Global().Set("epaxosVizDispatch", dispatchFunction)
50+
document := js.Global().Get("document")
51+
event := js.Global().Get("CustomEvent").New("epaxos-viz-ready")
52+
document.Call("dispatchEvent", event)
53+
select {}
54+
}
55+
56+
func (b *bridge) dispatch(_ js.Value, args []js.Value) (result any) {
57+
defer func() {
58+
if recovered := recover(); recovered != nil {
59+
result = marshalResponse(failure(sim.CodeInternal, "The EPaxos core rejected this request."))
60+
}
61+
}()
62+
if len(args) != 1 || args[0].Type() != js.TypeString {
63+
return marshalResponse(failure(sim.CodeInvalidRequest, "Pass one JSON request string."))
64+
}
65+
decoded, err := decodeRequest(args[0].String())
66+
if err != nil {
67+
return marshalResponse(failure(sim.CodeInvalidRequest, "The request is not valid JSON."))
68+
}
69+
return marshalResponse(b.handle(decoded))
70+
}
71+
72+
func decodeRequest(raw string) (request, error) {
73+
decoder := json.NewDecoder(strings.NewReader(raw))
74+
decoder.DisallowUnknownFields()
75+
var decoded request
76+
if err := decoder.Decode(&decoded); err != nil {
77+
return request{}, err
78+
}
79+
if err := decoder.Decode(new(any)); !errors.Is(err, io.EOF) {
80+
return request{}, errors.New("request has trailing JSON")
81+
}
82+
return decoded, nil
83+
}
84+
85+
func (b *bridge) handle(req request) response {
86+
switch req.Op {
87+
case "catalog":
88+
if req.Scenario != "" || req.Size != 0 || req.Index != 0 || req.Action != (sim.Action{}) {
89+
return failure(sim.CodeInvalidRequest, "Catalog does not accept additional fields.")
90+
}
91+
return response{OK: true, Scenarios: sim.Catalog()}
92+
case "scenario":
93+
if req.Scenario == "" || req.Size != 0 || req.Index != 0 || req.Action != (sim.Action{}) {
94+
return failure(sim.CodeInvalidRequest, "Choose one guided scenario.")
95+
}
96+
trace, err := sim.BuildScenario(req.Scenario)
97+
if err != nil {
98+
return fromError(err)
99+
}
100+
return response{OK: true, Trace: &trace}
101+
case "lab.reset":
102+
if req.Scenario != "" || req.Index != 0 || req.Action != (sim.Action{}) || (req.Size != 3 && req.Size != 5) {
103+
return failure(sim.CodeInvalidRequest, "Lab reset accepts only a cluster size.")
104+
}
105+
session, err := sim.NewSession(req.Size)
106+
if err != nil {
107+
return fromError(err)
108+
}
109+
frame, err := session.Seek(0)
110+
if err != nil {
111+
return fromError(err)
112+
}
113+
b.lab = session
114+
b.labCursor = 0
115+
b.labLength = 0
116+
return frameResponse(frame, false, false)
117+
case "lab.action":
118+
if req.Scenario != "" || req.Size != 0 || req.Index != 0 || b.lab == nil {
119+
return failure(sim.CodeInvalidRequest, "Reset the lab before dispatching an action.")
120+
}
121+
frame, err := b.lab.Dispatch(req.Action)
122+
if err != nil {
123+
return fromError(err)
124+
}
125+
b.labCursor = frame.Index
126+
b.labLength = frame.Index
127+
return frameResponse(frame, b.labCursor > 0, false)
128+
case "lab.seek":
129+
if req.Scenario != "" || req.Size != 0 || req.Action != (sim.Action{}) || b.lab == nil {
130+
return failure(sim.CodeInvalidRequest, "Reset the lab before seeking its history.")
131+
}
132+
frame, err := b.lab.Seek(req.Index)
133+
if err != nil {
134+
return fromError(err)
135+
}
136+
b.labCursor = frame.Index
137+
return frameResponse(frame, b.labCursor > 0, b.labCursor < b.labLength)
138+
default:
139+
return failure(sim.CodeInvalidRequest, "That operation is not supported.")
140+
}
141+
}
142+
143+
func frameResponse(frame sim.Frame, canBack, canForward bool) response {
144+
return response{OK: true, Frame: &frame, CanBack: &canBack, CanForward: &canForward}
145+
}
146+
147+
func fromError(err error) response {
148+
code := sim.CodeInternal
149+
var coded interface{ Code() string }
150+
if errors.As(err, &coded) {
151+
code = coded.Code()
152+
}
153+
return failure(code, err.Error())
154+
}
155+
156+
func failure(code, message string) response {
157+
return response{OK: false, Error: &errorResponse{Code: code, Message: message}}
158+
}
159+
160+
func marshalResponse(value response) string {
161+
encoded, err := json.Marshal(value)
162+
if err != nil {
163+
return `{"ok":false,"error":{"code":"internal","message":"The EPaxos response could not be encoded."}}`
164+
}
165+
return string(encoded)
166+
}

0 commit comments

Comments
 (0)