Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
a4d76c7
engine: replace Duktape 1.0.2 with QuickJS 2026-06-04
evgeny-boger Aug 13, 2026
6b7463c
wbrules: adapt error-format parsing and engine-specific test data
evgeny-boger Aug 13, 2026
ba7c5c3
build/docs: packaging for the QuickJS engine, samples, port notes
evgeny-boger Aug 13, 2026
cbe69b3
engine: fix bug classes surfaced by a 663-script wild-script corpus
evgeny-boger Aug 14, 2026
6c9b649
engine: TypeScript rule support via typescript-go (tsgo)
evgeny-boger Aug 14, 2026
e08541a
README: engine supports full ES2025 and most of ES2026 (probed)
evgeny-boger Aug 14, 2026
d79d2f1
engine: fix findings from adversarial multi-agent review
evgeny-boger Aug 14, 2026
ff18bf0
README: ES2026 support verified against the ratified spec
evgeny-boger Aug 14, 2026
ef5cd9f
ci: GitHub Actions test workflow; deb builds without tsgo binaries
evgeny-boger Aug 14, 2026
98febba
editor: on-demand Editor.Check and Editor.GetTypes RPC; drop retained…
evgeny-boger Aug 14, 2026
4f10029
editor test: expect Check and GetTypes in the announced RPC method set
evgeny-boger Aug 14, 2026
9d72713
changelog: 2.47.0~quickjs6
evgeny-boger Aug 14, 2026
4fc0650
ci: allow manual workflow dispatch
evgeny-boger Aug 14, 2026
33f302e
editor: serve Check from the background-verdict cache; fix review fin…
evgeny-boger Aug 14, 2026
27e893d
changelog: 2.47.0~quickjs7
evgeny-boger Aug 14, 2026
606018f
engine: fix findings from the from-scratch PR review
evgeny-boger Aug 14, 2026
5c2d53b
changelog: 2.47.0~quickjs8
evgeny-boger Aug 14, 2026
7023cea
Merge remote-tracking branch 'origin/master' into quickjs-port
evgeny-boger Aug 14, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
name: test

on:
push:
branches: [master, quickjs-port]
pull_request:
workflow_dispatch:

jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
submodules: recursive

- uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache: true

# wbgo.so is the wbgong implementation compiled as a Go plugin; the
# test binary and the plugin must be built by the same toolchain in
# the same job or plugin.Open refuses to load it.
- name: checkout wbgo-private
uses: actions/checkout@v4
with:
repository: wirenboard/wbgo-private
token: ${{ secrets.WBGO_PRIVATE_TOKEN }}
path: wbgo-private

- name: build wbgo.so plugin
run: |
cd wbgo-private
go build -buildmode=plugin -o ../wbrules/wbgo.so .

- name: vet
run: go vet ./...

- name: unit tests (quickjs shim)
run: go test -count=1 ./internal/quickjsduk/

- name: unit tests (engine)
run: go test -count=1 -timeout 900s ./wbrules/

# The TypeScript suite needs a tsgo binary (typescript-go requires
# Go >= 1.26 to build); it is skipped automatically when
# WB_RULES_TSGO is not set. Enable it here once tsgo artifacts are
# published or cached.
7 changes: 7 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -47,3 +47,10 @@ debian/.debhelper
### direnv ###
.direnv
.envrc
debian/wb-rules/
debian/files
debian/*.debhelper
debian/*.substvars
debian/debhelper-build-stamp
wbrules/wbgo.so
wb-rules
3 changes: 3 additions & 0 deletions .gitmodules
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
[submodule "third_party/quickjs"]
path = third_party/quickjs
url = https://github.com/bellard/quickjs
16 changes: 16 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@
PREFIX = /usr
DEB_TARGET_ARCH ?= armhf
WBGO_LOCAL_PATH ?= .
TSGO_LOCAL_PATH ?= .
GO_ARCH_amd64 := amd64
GO_ARCH_arm64 := arm64
GO_ARCH_armhf := arm

ifeq ($(DEB_TARGET_ARCH),armhf)
GO_ENV := GOARCH=arm GOARM=6 CC_FOR_TARGET=arm-linux-gnueabihf-gcc CC=$$CC_FOR_TARGET CGO_ENABLED=1
Expand All @@ -19,6 +23,12 @@ GOTEST ?= $(GO) test
GCFLAGS :=
LDFLAGS := -X main.version=`git describe --tags --always --dirty`
GO_FLAGS := -buildvcs=false

# Go forces -fuse-ld=gold for arm64 external linking (workaround for an
# ancient binutils bug); binutils >= 2.44 no longer ships gold.
ifneq ($(filter arm64 armhf,$(DEB_TARGET_ARCH)),)
LDFLAGS += -extldflags=-fuse-ld=bfd
endif
GO_TEST_FLAGS := -v -cover

ifeq ($(DEBUG),)
Expand Down Expand Up @@ -56,5 +66,11 @@ install:
install -Dm0644 modules/*.js -t $(DESTDIR)$(PREFIX)/share/wb-rules-modules
install -Dm0644 rules/load_alarms.js -t $(DESTDIR)$(PREFIX)/share/wb-rules
install -Dm0644 $(WBGO_LOCAL_PATH)/$(DEB_TARGET_ARCH).wbgo.so $(DESTDIR)$(PREFIX)/lib/wb-rules/wbgo.so
@if [ -f "$(TSGO_LOCAL_PATH)/tsgo-$(GO_ARCH_$(DEB_TARGET_ARCH))" ]; then \
install -Dm0755 $(TSGO_LOCAL_PATH)/tsgo-$(GO_ARCH_$(DEB_TARGET_ARCH)) $(DESTDIR)$(PREFIX)/lib/wb-rules/tsgo; \
else \
echo "NOTE: tsgo binary not found at $(TSGO_LOCAL_PATH); packaging without TypeScript support (wb-rules will reject .ts files with a clear error)"; \
fi
install -Dm0644 types/wb-rules.d.ts $(DESTDIR)$(PREFIX)/share/wb-rules/types/wb-rules.d.ts
install -Dm0644 rules/alarms.conf -t $(DESTDIR)/etc/wb-rules
install -Dm0644 rules/alarms.schema.json -t $(DESTDIR)$(PREFIX)/share/wb-mqtt-confed/schemas
156 changes: 156 additions & 0 deletions PORT-QUICKJS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
# wb-rules on QuickJS

This tree is wb-rules with the Duktape 1.0.2 engine replaced by **QuickJS
2026-06-04** (Bellard's latest release). The engine is pinned as a git
submodule at `third_party/quickjs` → bellard/quickjs commit `3d5e064`
(verified byte-identical to the official release tarball). The port keeps
the wbrules engine code intact by providing a QuickJS-backed drop-in for
the go-duktape API.

## Engine shipping model

- `third_party/quickjs` — submodule, unmodified upstream. If fixes are ever
needed, fork bellard/quickjs into the wirenboard org, point `.gitmodules`
there, and carry patches as commits — they rebase cleanly on upstream.
- `internal/quickjsduk/qjs_*.c` — five one-line compilation wrappers that
`#include` the submodule sources. cgo only auto-compiles `.c` files inside
the package directory, so these wrappers are the entire engine build:
no Makefile step, no system library, and cross-compilation (armhf/arm64)
rides the existing `CGO_ENABLED=1 CC=<cross-gcc>` flow unchanged. The
wrappers also carry `#pragma GCC optimize("wrapv")` (upstream builds with
-fwrapv, which cgo's flag filter rejects) and `CONFIG_VERSION` (update it
together with the submodule).
- Clone with `git clone --recursive`, or `git submodule update --init`.

## Debian package

`dpkg-buildpackage -b` works with two packaging edits (committed): system
`golang-go` instead of the internal `golang-1.26-go`, and the matching PATH
in debian/rules. Build wbgo.so from wbgo-private **with the same flags the
deb uses** (`-trimpath -ldflags "-s -w"`) so the plugin/binary pair inside
the package match, and pass `WBGO_LOCAL_PATH`:

```sh
cd wbgo-private && go build -trimpath -buildvcs=false -ldflags "-s -w" \
-buildmode=plugin -o amd64.wbgo.so .
cd ../wb-rules && WBGO_LOCAL_PATH=../wbgo-private dpkg-buildpackage -b -us -uc
```

Built and smoke-tested here: wb-rules_2.47.0~quickjs1_amd64.deb installs,
the service unit registers, and the engine runs rules end-to-end against a
real mosquitto broker. Note the flag coupling both ways: test binaries are
NOT built with -trimpath, so tests need a non-trimpath wbgo.so — the deb
build overwrites wbrules/wbgo.so with the trimpath one (rebuild it plain
before running go test again).

## What changed

1. **`internal/quickjsduk/`** — a Go package with module path
`github.com/wirenboard/go-duktape`, wired in via `go.mod`:
`replace github.com/wirenboard/go-duktape => ./internal/quickjsduk`.
It reimplements the 70-method go-duktape surface wbrules uses on QuickJS
(libquickjs.a built from source, cgo):
- Duktape's value-stack semantics, incl. fresh stack frames for Go-func
calls, `PushThis`, and negative-rc throws with Duktape's exact error
strings (`Error: error error (rc -100)` — tests assert them);
- `PushThreadNewGlobalenv` → `JS_NewContext` realm in the shared runtime
(the per-script-file isolation mechanism), realm handle GC frees the realm;
- heap stash, enumerator protocol, JSON codec, Go object wrappers
(custom-class objects get `Object.prototype` — QuickJS default is null);
- Duktape 1.x CommonJS: global `require()` + `Duktape.modSearch`, per-realm
module cache, relative-id resolution, cycle-safe pre-registration,
`module.filename`/`module.static` support;
- `JS_UpdateStackTop` on every API entry — Go schedules goroutines across
OS threads and QuickJS's stack-overflow heuristic is anchored to the
creating thread's stack otherwise (symptom: spurious
"Maximum call stack size exceeded" from any nested JS call).

2. **`wbrules/escontext.go`** — two engine-format adaptations:
- `fileRx` parses QuickJS stack lines (`at fn (file:line:col)` and the
syntax-error `at file:line:col` form) instead of Duktape's;
- `GetESError` takes the message from the error value itself (Duktape's
`.stack` embeds `"Error: msg"` as its first line; QuickJS's holds only
frame lines).

3. **Everything else in `wbrules/` is untouched.** lib.js runs as-is
(ES6 Proxy support in QuickJS covers what the Duktape fork provided).

## Test infrastructure

`wbgo.so` is built from wirenboard/wbgo-private:

```sh
cd wbgo-private && go build -buildvcs=false -buildmode=plugin -o amd64.wbgo.so .
cp amd64.wbgo.so ../wb-rules/wbrules/wbgo.so
```

Do NOT build the plugin with -trimpath unless the test binary uses it too —
Go plugin loading requires identical build IDs for shared packages.

## Test status (2026-08-12, real production wbgo.so from wbgo-private)

**All 36 test suites pass** against the production driver plugin, built from
wirenboard/wbgo-private with matching toolchain and dependency versions
(build the plugin WITHOUT -trimpath so shared-package build IDs match the
test binary; go build -buildvcs=false -buildmode=plugin).

Three test-data updates were needed, each an engine-behavior difference
documented below: rule location line attribution (24→17), StorableObject
for-in fields, and one log expectation in the email suite that had asserted
Duktape's CESU-8 surrogate leak — QuickJS logs the emoji as proper UTF-8
(the transmitted MIME message was already byte-identical).

## Engine semantics ported (hard-won details)

- **Calling-realm dispatch**: QuickJS invokes C functions in the function's
*creation* realm; Duktape uses the calling thread's context. wb-rules keys
per-file state (rule registries) on that context, so the shim tracks the
actively-executing realm and dispatches Go callbacks against it.
- **Module semantics**: `require()` caches per realm (per script file) — a
module shared by two rule files initializes twice, as wb-rules expects;
relative ids resolve against the requiring module's id; require-cycles get
the partially-built exports (pre-registration).
- **Error text parity**: negative-rc Go-function errors produce Duktape's
exact strings ("Error: error error (rc -100)"); ESError messages embed the
stack the way Duktape's `.stack` did (tests regexp-match file:line in them).
- **Two documented test-data changes**: rule locations attribute a multi-line
`defineRule(...)` call to its FIRST line (QuickJS) instead of its last
(Duktape) — `rule_location_test.go` expectations updated 24→17; and
`scripts/lib.js` StorableObject bookkeeping fields are now non-enumerable
(Duktape's legacy `enumerate` Proxy trap hid them; spec-correct for-in
walks the proxy prototype chain).

## Hardware validation (WB8, arm64, 2026-08-13)

Deployed to a Wiren Board 8 (trixie, 4 GB) over the stock 2.46.2 install;
all production rule files load with zero script errors. Measured back-to-back
on the same device, same ruleset (steady state, 90 s after restart):

| metric | Duktape 2.46.2 | QuickJS 2.47.0~quickjs1 |
|---|---|---|
| RSS | 37.6 MB | 36.7 MB |
| PSS | 36.1 MB | 35.3 MB |
| MQTT reaction latency, median (n=300) | 6.98 ms | 7.61 ms |
| latency p99 | 12.3 ms | 14.7 ms |
| ES5 compute benchmark (2M-iter loop + fib(23)) | ~1300 ms | **~310 ms (4.2x faster)** |

Reaction latency is dominated by the MQTT/driver path — engine choice is
noise there. Raw compute is ~4x faster on QuickJS; memory is at parity.

## Pending-job pump (promises)

QuickJS queues promise reactions as pending jobs; Duktape 1.x had no
promises, so wb-rules never ran a microtask queue. The shim drains
`JS_ExecutePendingJob` whenever control returns to Go from the outermost JS
entry — async/await and promise chains in rules resolve as they would on an
event-loop runtime. (Found on-device: `Promise.withResolvers` never settled
until this was added.)

## Samples

- `sample-es2025.js` — ES2024/ES2025 feature showcase (class private
fields/static blocks, toSorted/findLast/at, Object.groupBy, iterator
helpers, Set algebra, Promise.withResolvers, RegExp.escape, v-flag
regexps, BigInt, arrow-function rule callbacks). Deployable as-is.
- `sample-bench.js` — the ES5 benchmark rules used for the numbers above
(runs on both engines).
40 changes: 40 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ Rule engine for Wiren Board, version 2.0

**Содержание**

- [Движок QuickJS и TypeScript](#движок-quickjs-и-typescript)
- [Правила](#правила)
- [Определение правил](#определение-правил)
- [Типы правил](#типы-правил)
Expand All @@ -31,6 +32,45 @@ Rule engine for Wiren Board, version 2.0
- [Ограничения](#ограничения)


## Движок QuickJS и TypeScript

Начиная с версии 2.47 сценарии выполняются движком
[QuickJS](https://bellard.org/quickjs/) (вместо Duktape 1.0.2): в правилах
доступен современный JavaScript — полный ES2025 и почти весь ES2026
(`Map.prototype.getOrInsert`, `Iterator.concat`, `JSON.rawJSON`,
`Uint8Array.fromBase64`, `Math.sumPrecise`, `Error.isError`; из ES2026 не
поддерживается только `Array.fromAsync`, а `using`/`DisposableStack` и Temporal —
это уже ES2027)
(классы с приватными полями, `async`/`await` и промисы, `Object.groupBy`,
`toSorted`/`findLast`, стрелочные функции в `defineRule` и т.д.). Готовый
пример: [sample-es2025.js](./sample-es2025.js). Движок закреплён как
git-подмодуль `third_party/quickjs`; детали портирования — в
[PORT-QUICKJS.md](./PORT-QUICKJS.md).

Ограничение для асинхронного кода: регистрируйте правила и таймеры
синхронно при загрузке файла — вызовы `defineRule`/`setTimeout` после
`await` не привязываются к файлу сценария (см. PORT-QUICKJS.md).

### TypeScript

Файлы `*.ts` в каталогах сценариев поддерживаются напрямую (компилятор —
[typescript-go](https://github.com/microsoft/typescript-go), поставляется
как `/usr/lib/wb-rules/tsgo`). Логика загрузки: «сначала выполняем, потом
проверяем» — транспиляция занимает ~1 мс и файл запускается сразу; полная
проверка типов идёт в фоне, найденные ошибки появляются в журнале правил
как предупреждения `TS check: файл:строка:колонка` и никогда не
задерживают и не останавливают выполнение. Синтаксические ошибки, как и в
JS, препятствуют загрузке файла с указанием строки исходника.

Номера строк в сообщениях об ошибках указывают на исходный `.ts` файл
(используются source maps). Декларации типов встроенного API — в
[types/wb-rules.d.ts](./types/wb-rules.d.ts); фоновая проверка подключает
их автоматически, поэтому `defineRule`, `dev`, `log` и т.д. типизированы.
Пример типизированного сценария: `wbrules/testrules_ts.ts`.

Флаги: `-tsgo` — путь к компилятору (пустое значение отключает поддержку
TypeScript), `-ts-types` — путь к декларациям API.

## Правила

Правила — специальные скрипты, предназначенные для программирования контроллеров Wiren Board. Правила представляют собой функции с определенным набором параметров.
Expand Down
Loading
Loading