Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- Runtime: optional shared invoke concurrency budgets, cooperative cancellation on exit and shutdown, per-universe accumulator and tracking limits, and strict observer registration checks. Existing constructors preserve zero-value policies.
- Runtime: optional context-aware snapshot capture/restoration and invoke lifecycle interfaces; reject synchronous callback reentry when callers preserve the callback context.
- Debugger bot: cancellable event processing, checked snapshot errors, and an optional history retention limit.

### Security

- Studio: metadata pointer writes and object merges use own data properties, preserving JSON keys without traversing inherited properties.
- Studio: update compatible dependency overrides, including `fast-uri` to `^3.1.8`.

### Fixed

- Runtime: honor cancellation while waiting for execution locks and between synchronous callbacks; preserve bounded tracking during failed entry rollback.
- CLI: avoid reflection panics when copying contexts with private fields or typed nil pointers; handle empty lists and history independently of saved checkpoints.
- Studio: reject malformed JSON without throwing and resolve definition references only from own properties, while allowing explicitly declared names such as `constructor`.
- Web Component: support registering multiple tag names and cover attributes, properties, lifecycle, and DOM events with integration tests.
- Studio: fit header, machine panel, and search controls within mobile viewports. Keep Babel 7 overrides scoped to Babel 7 so the Stryker 10 instrumenter can use Babel 8.
- Studio tests: exclude Stryker sandboxes from normal Vitest discovery to prevent duplicate or instrumented suites from running as application tests.
- Runtime: validate all included universe snapshots before applying them; restore metadata and tracking instead of retaining newer entries, synchronize metadata restoration with invokes, and reconstruct empty superposition accumulators and final-state flags.
- Runtime: synchronize custom executor registration and lookup; accept custom implementations of the public `Event` interface in accumulators.
- Runtime: return errors for nil machine/universe models and nil events; reject already-canceled `SendEvent` calls before event admission, including cancellation while waiting for the machine lock.
Expand Down
74 changes: 68 additions & 6 deletions debugger/bot/bot.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,17 +50,23 @@ func NewBot(
return nil, fmt.Errorf("event provider cannot be nil")
}

initial, err := captureSnapshot(context.Background(), qm)
if err != nil {
return nil, fmt.Errorf("error capturing initial snapshot: %w", err)
}
b := &bot{
qm: qm,
initialSnapshot: qm.GetSnapshot(),
initialSnapshot: initial,
eventProvider: eventProvider,
initQuantumMachine: initQuantumMachine,
}

for _, opt := range opts {
opt(b)
}

if b.historyLimit < 0 {
return nil, fmt.Errorf("history limit must not be negative")
}
return b, nil
}

Expand All @@ -71,6 +77,7 @@ type bot struct {
initQuantumMachine bool
history []*EventHistory
ignoreUnhandledEvents bool
historyLimit int
}

type BotOption func(*bot)
Expand All @@ -81,10 +88,46 @@ func WithIgnoreUnhandledEvents(ignore bool) BotOption {
}
}

// WithHistoryLimit retains the latest entries; zero preserves unlimited history.
func WithHistoryLimit(limit int) BotOption {
return func(b *bot) { b.historyLimit = limit }
}

func captureSnapshot(ctx context.Context, qm instrumentation.QuantumMachine) (*instrumentation.MachineSnapshot, error) {
var snapshot *instrumentation.MachineSnapshot
var err error
if contextual, ok := qm.(instrumentation.ContextSnapshotProvider); ok {
snapshot, err = contextual.GetSnapshotContext(ctx)
} else if checked, ok := qm.(instrumentation.SnapshotProvider); ok {
snapshot, err = checked.GetSnapshotWithError()
} else {
snapshot = qm.GetSnapshot()
}
if err != nil {
return nil, err
}
if snapshot == nil {
return nil, fmt.Errorf("snapshot capture returned nil")
}
return snapshot, nil
}

func (b *bot) Run(ctx context.Context, machineContext any) error {
if ctx == nil {
return fmt.Errorf("context must not be nil")
}
if err := ctx.Err(); err != nil {
return err
}
b.history = nil
if err := b.qm.LoadSnapshot(b.initialSnapshot, machineContext); err != nil {
return fmt.Errorf("error loading initial snapshot: %w", err)
var restoreErr error
if contextual, ok := b.qm.(instrumentation.ContextSnapshotProvider); ok {
restoreErr = contextual.LoadSnapshotContext(ctx, b.initialSnapshot, machineContext)
} else {
restoreErr = b.qm.LoadSnapshot(b.initialSnapshot, machineContext)
}
if restoreErr != nil {
return fmt.Errorf("error loading initial snapshot: %w", restoreErr)
}

if b.initQuantumMachine {
Expand All @@ -94,10 +137,20 @@ func (b *bot) Run(ctx context.Context, machineContext any) error {
}

for {
event, err := b.eventProvider(b.qm.GetSnapshot())
if err := ctx.Err(); err != nil {
return err
}
snapshot, err := captureSnapshot(ctx, b.qm)
if err != nil {
return fmt.Errorf("error capturing snapshot: %w", err)
}
event, err := b.eventProvider(snapshot)
if err != nil {
return err
}
if err := ctx.Err(); err != nil {
return err
}
if event == nil {
break
}
Expand All @@ -114,10 +167,19 @@ func (b *bot) Run(ctx context.Context, machineContext any) error {
return fmt.Errorf("event '%s' was not handled", event.GetEventName())
}

snapshot, err = captureSnapshot(ctx, b.qm)
if err != nil {
return fmt.Errorf("error capturing event snapshot: %w", err)
}
b.history = append(b.history, &EventHistory{
Event: event,
Snapshot: b.qm.GetSnapshot(),
Snapshot: snapshot,
})
if b.historyLimit > 0 && len(b.history) > b.historyLimit {
copy(b.history, b.history[len(b.history)-b.historyLimit:])
clear(b.history[b.historyLimit:])
b.history = b.history[:b.historyLimit]
}
}

return nil
Expand Down
130 changes: 130 additions & 0 deletions debugger/bot/runtime_controls_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
package bot_test

import (
"context"
"errors"
"testing"
"time"

statepro "github.com/rendis/statepro/v3"
"github.com/rendis/statepro/v3/builtin"
"github.com/rendis/statepro/v3/debugger/bot"
"github.com/rendis/statepro/v3/instrumentation"
"github.com/rendis/statepro/v3/theoretical"
)

func TestBot_CancelaRestauracionMientrasMaquinaRealEjecutaCallback(t *testing.T) {
entered, release := make(chan struct{}), make(chan struct{})
if err := builtin.RegisterAction("test:bot-runtime-block", func(context.Context, instrumentation.ActionExecutorArgs) error {
close(entered)
<-release
return nil
}); err != nil {
t.Fatal(err)
}
defer builtin.RegisterAction("test:bot-runtime-block", nil)
initial := "idle"
qm, err := statepro.NewQuantumMachine(&theoretical.QuantumMachineModel{
ID: "machine", Initials: []string{"U:main"},
Universes: map[string]*theoretical.UniverseModel{"main": {
ID: "main", Initial: &initial, Realities: map[string]*theoretical.RealityModel{"idle": {
ID: "idle", Type: "transition", EntryActions: []*theoretical.ActionModel{{Src: "test:bot-runtime-block"}},
}},
}},
})
if err != nil {
t.Fatal(err)
}
b, err := bot.NewBot(qm, func(*instrumentation.MachineSnapshot) (instrumentation.Event, error) {
t.Error("proveedor ejecutado tras cancelacion")
return nil, nil
}, false)
if err != nil {
t.Fatal(err)
}
done := make(chan error, 1)
go func() { done <- qm.Init(context.Background(), nil) }()
defer func() {
close(release)
if err := <-done; err != nil {
t.Error(err)
}
}()
select {
case <-entered:
case <-time.After(time.Second):
t.Fatal("callback no iniciado")
}
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond)
defer cancel()
if err := b.Run(ctx, nil); !errors.Is(err, context.DeadlineExceeded) {
t.Fatal(err)
}
}

func TestBot_CancelacionAntesYDuranteProveedor(t *testing.T) {
for _, preCanceled := range []bool{false, true} {
qm := &hostileQM{MockQuantumMachine: MockQuantumMachine{snapshot: &instrumentation.MachineSnapshot{}}}
ctx, cancel := context.WithCancel(context.Background())
calls := 0
provider := func(*instrumentation.MachineSnapshot) (instrumentation.Event, error) {
calls++
cancel()
return &MockEvent{name: "handled"}, nil
}
b, err := bot.NewBot(qm, provider, false)
if err != nil {
t.Fatal(err)
}
if preCanceled {
cancel()
}
if err := b.Run(ctx, nil); !errors.Is(err, context.Canceled) {
t.Fatal(err)
}
if qm.sendCalls != 0 || (preCanceled && calls != 0) {
t.Fatal("cancelacion ejecuta trabajo adicional")
}
}
}

func TestBot_LimiteRetieneUltimosEventosYRechazaNegativos(t *testing.T) {
qm := &MockQuantumMachine{snapshot: &instrumentation.MachineSnapshot{}}
count := 0
provider := func(*instrumentation.MachineSnapshot) (instrumentation.Event, error) {
count++
if count > 5 {
return nil, nil
}
return &MockEvent{name: "handled"}, nil
}
b, err := bot.NewBot(qm, provider, false, bot.WithHistoryLimit(2))
if err != nil {
t.Fatal(err)
}
if err := b.Run(context.Background(), nil); err != nil {
t.Fatal(err)
}
if len(b.GetHistory()) != 2 {
t.Fatalf("historial sin limite: %d", len(b.GetHistory()))
}
if _, err := bot.NewBot(qm, provider, false, bot.WithHistoryLimit(-1)); err == nil {
t.Fatal("limite negativo aceptado")
}
}

type failingSnapshotMachine struct {
MockQuantumMachine
err error
}

func (m *failingSnapshotMachine) GetSnapshotWithError() (*instrumentation.MachineSnapshot, error) {
return nil, m.err
}
func TestBot_NoOcultaErroresDeCaptura(t *testing.T) {
failure := errors.New("snapshot failure")
qm := &failingSnapshotMachine{err: failure}
if _, err := bot.NewBot(qm, func(*instrumentation.MachineSnapshot) (instrumentation.Event, error) { return nil, nil }, false); !errors.Is(err, failure) {
t.Fatal(err)
}
}
8 changes: 5 additions & 3 deletions debugger/cli/helper.go
Original file line number Diff line number Diff line change
Expand Up @@ -151,11 +151,13 @@ func copyStructPointer(v any) any {
}

val := reflect.ValueOf(v)
if val.IsNil() {
return nil
}
structValue := val.Elem()
structCopy := reflect.New(structValue.Type()).Elem()
for i := 0; i < structValue.NumField(); i++ {
structCopy.Field(i).Set(structValue.Field(i))
}
// Copy the complete value; setting unexported fields individually panics.
structCopy.Set(structValue)

return structCopy.Addr().Interface()
}
Expand Down
11 changes: 10 additions & 1 deletion debugger/cli/history_viewer_model.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ var historySnapshotKeys = map[string]snapshotExtractor{
}

func buildHistoryViewerModel(prevModel *model, container *smContainer) (tea.Model, tea.Cmd) {
if len(container.snapshots) == 0 {
if len(container.history) == 0 {
return prevModel, nil
}

Expand Down Expand Up @@ -79,6 +79,9 @@ func historyViewerModelView(m *model) string {
hm := m.helperModel.(*list.Model)
item, _ := hm.SelectedItem().(*choice)
v1 := appStyle.Render(hm.View())
if item == nil {
return v1
}

h := item.obj.(*containerHistory)
part := buildSnapshotPartFromHistory(h, "m")
Expand All @@ -102,6 +105,9 @@ func historyViewerModelUpdate(m *model, teaMsg tea.Msg) (tea.Model, tea.Cmd) {
return m.prevModel, nil
case "r":
item, _ := hm.SelectedItem().(*choice)
if item == nil {
return m, nil
}
h := item.obj.(*containerHistory)
if err := m.container.qm.LoadSnapshot(h.snapshot, m.container.smContext); err != nil {
m.err = err
Expand All @@ -115,6 +121,9 @@ func historyViewerModelUpdate(m *model, teaMsg tea.Msg) (tea.Model, tea.Cmd) {
break
}
item, _ := hm.SelectedItem().(*choice)
if item == nil {
return m, nil
}
h := item.obj.(*containerHistory)
v := buildSnapshotPartFromHistory(h, msg.String())
return buildJsonViewerModel(m, v.title, v.content)
Expand Down
9 changes: 9 additions & 0 deletions debugger/cli/load_snapshot_model.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,9 @@ func loadSnapshotModelView(m *model) string {
v1 := appStyle.Render(hm.View())

item, _ := hm.SelectedItem().(*choice)
if item == nil {
return v1
}
h := item.obj.(*debuggerSnapshot)
part := buildSnapshotPart(h, "m")

Expand All @@ -85,11 +88,17 @@ func loadSnapshotModelUpdate(m *model, teaMsg tea.Msg) (tea.Model, tea.Cmd) {
break
}
item, _ := hm.SelectedItem().(*choice)
if item == nil {
return m, nil
}
dn := item.obj.(*debuggerSnapshot)
v := buildSnapshotPart(dn, msg.String())
return buildJsonViewerModel(m, v.title, v.content)
case "l":
item, _ := hm.SelectedItem().(*choice)
if item == nil {
return m, nil
}
dn := item.obj.(*debuggerSnapshot)
if err := m.container.qm.LoadSnapshot(dn.Snapshot, m.container.smContext); err != nil {
return m, nil
Expand Down
9 changes: 9 additions & 0 deletions debugger/cli/send_event_model.go
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,9 @@ func sendEventModelView(m *model) string {
})
v1 := appStyle.Render(hm.View())

if len(m.container.history) == 0 {
return v1
}
h := m.container.history[len(m.container.history)-1]
part := buildSnapshotPartFromHistory(h, "m")

Expand All @@ -128,11 +131,17 @@ func sendEventModelUpdate(m *model, teaMsg tea.Msg) (tea.Model, tea.Cmd) {
if isFiltering(hm) {
break
}
if len(m.container.history) == 0 {
return m, nil
}
h := m.container.history[len(m.container.history)-1]
v := buildSnapshotPartFromHistory(h, msg.String())
return buildJsonViewerModel(m, v.title, v.content)
case "enter":
item, _ := hm.SelectedItem().(*choice)
if item == nil {
return m, nil
}
event := item.obj.(*debuggerEvent)
evt := statepro.NewEventBuilder(event.Name).
SetData(event.Params).
Expand Down
Loading
Loading