statifier 0.0.1 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +35 -0
  3. data/{LICENSE.txt → LICENSE} +1 -1
  4. data/README.md +601 -16
  5. data/lib/statifier/accepts.rb +167 -0
  6. data/lib/statifier/basichttp/decode.rb +142 -0
  7. data/lib/statifier/basichttp/encoding.rb +224 -0
  8. data/lib/statifier/basichttp/net_http_transport.rb +157 -0
  9. data/lib/statifier/basichttp/outbound.rb +87 -0
  10. data/lib/statifier/basichttp/processor.rb +170 -0
  11. data/lib/statifier/basichttp/request.rb +94 -0
  12. data/lib/statifier/basichttp/response.rb +34 -0
  13. data/lib/statifier/basichttp.rb +47 -0
  14. data/lib/statifier/compiler/content.rb +131 -0
  15. data/lib/statifier/compiler/error.rb +30 -0
  16. data/lib/statifier/compiler/expressions.rb +64 -0
  17. data/lib/statifier/compiler/numbering.rb +145 -0
  18. data/lib/statifier/compiler/parts.rb +144 -0
  19. data/lib/statifier/compiler/walk.rb +61 -0
  20. data/lib/statifier/compiler.rb +82 -0
  21. data/lib/statifier/datamodel/data_binding.rb +114 -0
  22. data/lib/statifier/datamodel/delay.rb +54 -0
  23. data/lib/statifier/datamodel/location.rb +145 -0
  24. data/lib/statifier/datamodel/program.rb +82 -0
  25. data/lib/statifier/datamodel/reason_value.rb +42 -0
  26. data/lib/statifier/datamodel/reasons.rb +88 -0
  27. data/lib/statifier/datamodel/stores.rb +169 -0
  28. data/lib/statifier/datamodel/system_variables.rb +69 -0
  29. data/lib/statifier/datamodel.rb +120 -0
  30. data/lib/statifier/document/data.rb +36 -0
  31. data/lib/statifier/document/executable.rb +69 -0
  32. data/lib/statifier/document/invoke.rb +17 -0
  33. data/lib/statifier/document/states.rb +40 -0
  34. data/lib/statifier/document.rb +51 -0
  35. data/lib/statifier/driver/calls.rb +183 -0
  36. data/lib/statifier/driver/checks.rb +110 -0
  37. data/lib/statifier/driver/delivery.rb +45 -0
  38. data/lib/statifier/driver/invocations.rb +129 -0
  39. data/lib/statifier/driver/mailbox.rb +120 -0
  40. data/lib/statifier/driver/routing.rb +193 -0
  41. data/lib/statifier/driver/session_id.rb +40 -0
  42. data/lib/statifier/driver/work.rb +196 -0
  43. data/lib/statifier/driver.rb +205 -0
  44. data/lib/statifier/effect.rb +208 -0
  45. data/lib/statifier/event.rb +67 -0
  46. data/lib/statifier/frozen.rb +22 -0
  47. data/lib/statifier/interpreter/blocks.rb +142 -0
  48. data/lib/statifier/interpreter/content.rb +228 -0
  49. data/lib/statifier/interpreter/entering.rb +141 -0
  50. data/lib/statifier/interpreter/exit_entry.rb +174 -0
  51. data/lib/statifier/interpreter/foreach.rb +83 -0
  52. data/lib/statifier/interpreter/invoke/arguments.rb +52 -0
  53. data/lib/statifier/interpreter/invoke/passes.rb +143 -0
  54. data/lib/statifier/interpreter/invoke.rb +145 -0
  55. data/lib/statifier/interpreter/loop.rb +170 -0
  56. data/lib/statifier/interpreter/machine_state.rb +152 -0
  57. data/lib/statifier/interpreter/macrostep.rb +185 -0
  58. data/lib/statifier/interpreter/name_match.rb +43 -0
  59. data/lib/statifier/interpreter/selection.rb +221 -0
  60. data/lib/statifier/interpreter/send.rb +223 -0
  61. data/lib/statifier/interpreter/step.rb +138 -0
  62. data/lib/statifier/interpreter/stop.rb +66 -0
  63. data/lib/statifier/interpreter/targets.rb +107 -0
  64. data/lib/statifier/interpreter.rb +57 -0
  65. data/lib/statifier/lowering/branches.rb +84 -0
  66. data/lib/statifier/lowering/data.rb +102 -0
  67. data/lib/statifier/lowering/error.rb +102 -0
  68. data/lib/statifier/lowering/executable.rb +106 -0
  69. data/lib/statifier/lowering/placement.rb +65 -0
  70. data/lib/statifier/lowering/reading.rb +53 -0
  71. data/lib/statifier/lowering/states.rb +111 -0
  72. data/lib/statifier/lowering.rb +129 -0
  73. data/lib/statifier/machine/content.rb +81 -0
  74. data/lib/statifier/machine/data.rb +42 -0
  75. data/lib/statifier/machine/states.rb +56 -0
  76. data/lib/statifier/machine/values.rb +37 -0
  77. data/lib/statifier/machine.rb +103 -0
  78. data/lib/statifier/position/rebuild.rb +84 -0
  79. data/lib/statifier/position/shape.rb +73 -0
  80. data/lib/statifier/position/text.rb +43 -0
  81. data/lib/statifier/position.rb +206 -0
  82. data/lib/statifier/validator/attributes.rb +35 -0
  83. data/lib/statifier/validator/context.rb +74 -0
  84. data/lib/statifier/validator/data.rb +136 -0
  85. data/lib/statifier/validator/error.rb +90 -0
  86. data/lib/statifier/validator/executable.rb +139 -0
  87. data/lib/statifier/validator/history.rb +81 -0
  88. data/lib/statifier/validator/messages.rb +142 -0
  89. data/lib/statifier/validator/states.rb +161 -0
  90. data/lib/statifier/validator.rb +94 -0
  91. data/lib/statifier/version.rb +4 -1
  92. data/lib/statifier/xml/character_data.rb +142 -0
  93. data/lib/statifier/xml/cursor.rb +131 -0
  94. data/lib/statifier/xml/markup.rb +153 -0
  95. data/lib/statifier/xml/nodes.rb +50 -0
  96. data/lib/statifier/xml/parser.rb +121 -0
  97. data/lib/statifier/xml/scope.rb +46 -0
  98. data/lib/statifier/xml/tags.rb +112 -0
  99. data/lib/statifier/xml.rb +85 -0
  100. data/lib/statifier.rb +386 -2
  101. metadata +129 -20
  102. data/.gitignore +0 -8
  103. data/CODE_OF_CONDUCT.md +0 -74
  104. data/Gemfile +0 -11
  105. data/Gemfile.lock +0 -33
  106. data/Rakefile +0 -10
  107. data/bin/console +0 -8
  108. data/bin/setup +0 -6
  109. data/statifier.gemspec +0 -34
@@ -0,0 +1,183 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Statifier
4
+ module Driver
5
+ # The bodies of the driver's calls, which {Statifier.start},
6
+ # {Statifier.step}, {Statifier.advance}, {Statifier.report_send_failed},
7
+ # {Statifier.configuration} and {Statifier.done?} answer through, and
8
+ # the checks on what a host passes them.
9
+ #
10
+ # @api private
11
+ module Calls
12
+ module_function
13
+
14
+ # {Statifier.start}'s body.
15
+ #
16
+ # @return [Result]
17
+ def start(machine, options)
18
+ options = Checks.start_options(options)
19
+ types, session_id = options.values_at(:types, :session_id)
20
+ started = Interpreter::Loop.initialize(machine, **options.slice(:session_id, :clock, :datamodel, :trace,
21
+ :max_macrostep_rounds), **seams(options))
22
+ work = Run.begin_call(fresh(started.state, session_id, types), options[:processors], options[:clock])
23
+ ok(Run.drain(Run.perform(work, started.effects)).moved)
24
+ end
25
+
26
+ # What the driver declares to the interpreter as the chart starts: the
27
+ # registered types, their `_ioprocessors` entries and the routes.
28
+ def seams(options)
29
+ types, session_id = options.values_at(:types, :session_id)
30
+ { send_types: (types unless types.empty?), ioprocessors: entries(options[:processors], types, session_id),
31
+ routes: own_routes(session_id) }
32
+ end
33
+
34
+ # The driver's state over a machine state that has just started.
35
+ def fresh(machine_state, session_id, types)
36
+ State.new(machine_state:, session_id:, now_ms: 0, timers: [].freeze, timer_sequence: 0,
37
+ external_queue: [].freeze, held_sends: {}.freeze, halted: nil, done: nil, send_types: types,
38
+ invocations: {}.freeze, mailbox: [].freeze, invoked_as: nil, cancelled: false)
39
+ end
40
+
41
+ # {Statifier.step}'s body.
42
+ #
43
+ # @return [Result]
44
+ def step(state, name, data:, processors:, clock:)
45
+ work = Run.begin_call(Checks.state(state, processors), processors, clock)
46
+ raise TypeError, "an event name is a String, got #{name.class}" unless name.is_a?(String)
47
+ return refused(:not_running) unless state.machine_state.running
48
+
49
+ ok(Run.drain(Routing.enqueue(work, Event.external(name, data:), counted: false).stamped).moved)
50
+ end
51
+
52
+ # {Statifier.report_send_failed}'s body: the failed send's
53
+ # `error.communication` delivered onto the internal queue and the chart
54
+ # run to a stable configuration, then the external queue taken, as a
55
+ # step takes it. Refused once the chart has stopped, and while a
56
+ # macrostep's round budget has halted it, as the reference's session
57
+ # drops a report then.
58
+ #
59
+ # @return [Result]
60
+ def report_send_failed(machine, state, send, options)
61
+ processors, clock = Checks.report(machine, state, send, options).values_at(:processors, :clock)
62
+ work = Run.begin_call(Checks.state(state, processors), processors, clock)
63
+ return refused(:not_running) unless state.machine_state.running
64
+ return refused(:halted) unless state.halted.nil?
65
+
66
+ work, deferred = Delivery.failed(work.stamped, send)
67
+ ok(Run.drain(Run.perform(work, deferred)).moved)
68
+ end
69
+
70
+ # {Statifier.advance}'s body: the clock moved `milliseconds` forward, every
71
+ # timer due by then, anywhere in the tree of sessions, fired in
72
+ # due-then-scheduled order, each run to completion before the next: the
73
+ # session it fired in, then each session above it.
74
+ #
75
+ # @return [Result]
76
+ def advance(state, milliseconds, processors:, clock:)
77
+ work = Run.begin_call(Checks.state(state, processors), processors, clock)
78
+ return refused(:invalid_duration) unless duration?(milliseconds)
79
+
80
+ until_ms = state.now_ms + milliseconds
81
+ while (found = next_due(work.state, until_ms))
82
+ path, timer = found
83
+ work = fire_at(work.with_state(now_ms: timer.due_ms), path, timer)
84
+ end
85
+ ok(work.with_state(now_ms: until_ms).moved)
86
+ end
87
+
88
+ # The timer fired in the session `path` (the invoke ids from this
89
+ # session down) names, each session on the way drained after it.
90
+ def fire_at(work, path, timer)
91
+ return fire(due(work, timer), timer.delayed) if path.empty?
92
+
93
+ Run.drain(Mailbox.within(work, path.first) { fire_at(_1, path.drop(1), timer) })
94
+ end
95
+
96
+ # Whether `value` is a duration the clock can move by: a real number,
97
+ # finite and not negative.
98
+ def duration?(value) = value.is_a?(Numeric) && value.real? && value.finite? && value >= 0
99
+
100
+ # The work with `timer` taken off the pending timers and the clock at
101
+ # its due time.
102
+ def due(work, timer)
103
+ work.with_state(timers: work.state.timers.reject { _1.equal?(timer) }.freeze, now_ms: timer.due_ms)
104
+ end
105
+
106
+ # The earliest pending timer due by `until_ms` in the tree, the first
107
+ # scheduled among equals, as `[path, timer]`, or nil.
108
+ def next_due(state, until_ms)
109
+ pending(state, []).select { _2.due_ms <= until_ms }.min_by { [_2.due_ms, _2.sequence] }
110
+ end
111
+
112
+ # Every pending timer of the tree under `state`, each with the path to
113
+ # its session.
114
+ def pending(state, path)
115
+ state.timers.map { [path, _1] } +
116
+ state.invocations.flat_map { |id, invocation| pending(invocation.child, path + [id]) }
117
+ end
118
+
119
+ # A fired timer's send, routed as an immediate one is but never
120
+ # counted, then the external queue taken. While a macrostep's budget
121
+ # has halted the session, a send to the session itself still joins
122
+ # its external queue, and any other is dropped, as the reference drops
123
+ # it.
124
+ def fire(work, send)
125
+ unless work.state.halted.nil?
126
+ return work unless Routing.destination(work, send) == :external
127
+
128
+ return Routing.enqueue(work, Routing.delivered_event(send, work.state.session_id), counted: false)
129
+ end
130
+
131
+ work, deferred = Routing.route(work.stamped, send, undelayed: false)
132
+ Run.drain(Run.perform(work, deferred))
133
+ end
134
+
135
+ # {Statifier.configuration}'s body: the active states' ids, the root
136
+ # left out, sorted. A state the document gave no id is named `#` and
137
+ # its index, a spelling no XML id can take.
138
+ #
139
+ # @return [Result]
140
+ def configuration(state)
141
+ Checks.state(state, nil)
142
+ ok(names(state.machine_state.machine, state.machine_state.configuration - [0]))
143
+ end
144
+
145
+ # {Statifier.done?}'s body.
146
+ #
147
+ # @return [Result]
148
+ def done?(state)
149
+ Checks.state(state, nil)
150
+ done = state.done
151
+ return ok(DoneStatus.new(done: false, donedata: nil, configuration: nil)) if done.nil?
152
+
153
+ ok(DoneStatus.new(done: true, donedata: done.donedata,
154
+ configuration: names(state.machine_state.machine, done.configuration - [0])))
155
+ end
156
+
157
+ # The states at `indexes` by name, sorted, a frozen list.
158
+ def names(machine, indexes) = indexes.map { machine.state(_1).id || "##{_1}" }.sort.freeze
159
+
160
+ # A successful result.
161
+ def ok(value) = Result.new(value:, error: nil)
162
+
163
+ # A refused call.
164
+ def refused(reason) = Result.new(value: nil, error: reason)
165
+
166
+ # The routes a session the driver runs can reach: itself, its parent
167
+ # when it has one, and its live invocations, by id.
168
+ def own_routes(session_id, parent: false, invokes: [].freeze)
169
+ Interpreter::Targets::Routes.new(sessions: [session_id].freeze, parent?: parent, invokes: invokes.freeze)
170
+ end
171
+
172
+ # Each registered type's `_ioprocessors` entry, asked of its processor
173
+ # once as the chart starts; empty when it answers none.
174
+ def entries(processors, types, session_id)
175
+ context = Context.new(session_id:)
176
+ types.to_h do |type|
177
+ processor = processors[type]
178
+ [type, processor.respond_to?(:ioprocessors_entry) ? processor.ioprocessors_entry(type, context) : {}]
179
+ end
180
+ end
181
+ end
182
+ end
183
+ end
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Statifier
4
+ module Driver
5
+ # The checks on what a host passes the driver's calls.
6
+ #
7
+ # @api private
8
+ module Checks
9
+ module_function
10
+
11
+ # The options {Statifier.start} takes, as they stand when the host
12
+ # names none.
13
+ START_OPTIONS = {
14
+ session_id: nil, datamodel: {}.freeze, processors: {}.freeze, max_macrostep_rounds: MAX_MACROSTEP_ROUNDS,
15
+ trace: false, clock: nil
16
+ }.freeze
17
+
18
+ # {Statifier.start}'s options, after checking them, with the
19
+ # registered `types` and the `session_id`, minted when the host named
20
+ # none.
21
+ #
22
+ # @raise [ArgumentError] for an option {START_OPTIONS} does not name,
23
+ # or a round budget that is neither a positive Integer nor
24
+ # `:infinity`
25
+ def start_options(options)
26
+ options = known(options, START_OPTIONS)
27
+ budget(options[:max_macrostep_rounds])
28
+ options.merge(types: registered_types(options[:processors]), session_id: session_id(options[:session_id]))
29
+ end
30
+
31
+ # The registered types, sorted, after checking the processors.
32
+ #
33
+ # @raise [TypeError] when `processors` is not a Hash of String types to
34
+ # objects answering `deliver`
35
+ def registered_types(processors)
36
+ raise TypeError, "processors is a Hash of send types to processors" unless processors.is_a?(Hash)
37
+
38
+ processors.each do |type, processor|
39
+ raise TypeError, "a send type is a String, got #{type.inspect}" unless type.is_a?(String)
40
+ raise TypeError, "the processor for #{type.inspect} does not answer deliver" \
41
+ unless processor.respond_to?(:deliver)
42
+ end
43
+ processors.keys.sort.freeze
44
+ end
45
+
46
+ # `state`, after checking it, and, unless `processors` is nil, that
47
+ # they register the types the chart was started with.
48
+ #
49
+ # @raise [TypeError] when `state` is not a driver state
50
+ # @raise [ArgumentError] when the processors name other types
51
+ def state(state, processors)
52
+ raise TypeError, "expected a driver state a call answered, got #{state.class}" unless state.is_a?(State)
53
+ return state if processors.nil? || registered_types(processors) == state.send_types
54
+
55
+ raise ArgumentError, "processors must register the types the chart started with: #{state.send_types}"
56
+ end
57
+
58
+ # The options {Statifier.report_send_failed} takes, as they stand when
59
+ # the host names none.
60
+ REPORT_OPTIONS = { reason: nil, processors: {}.freeze, clock: nil }.freeze
61
+
62
+ # A failed-send report's options, after checking them and the chart and
63
+ # the send it names against `state`.
64
+ #
65
+ # @raise [TypeError] when `state` is not a driver state, `machine` is
66
+ # not a compiled machine or `send` is not a send a processor is handed
67
+ # @raise [ArgumentError] for an option {REPORT_OPTIONS} does not name, or
68
+ # when `state` is not an execution of `machine`
69
+ def report(machine, state, send, options)
70
+ options = known(options, REPORT_OPTIONS)
71
+ raise TypeError, "expected a compiled machine, got #{machine.class}" unless machine.is_a?(Machine)
72
+ raise TypeError, "expected a Statifier::Effect::Send or SendDelayed, got #{send.class}" \
73
+ unless send.is_a?(Effect::Send) || send.is_a?(Effect::SendDelayed)
74
+ return options if state(state, nil).machine_state.machine == machine
75
+
76
+ raise ArgumentError, "the state is not an execution of the machine named"
77
+ end
78
+
79
+ # `options` over `defaults`, after checking that `defaults` names
80
+ # every one.
81
+ #
82
+ # @raise [ArgumentError] for an option `defaults` does not name
83
+ def known(options, defaults)
84
+ unknown = options.keys - defaults.keys
85
+ raise ArgumentError, "unknown options: #{unknown.inspect}" unless unknown.empty?
86
+
87
+ defaults.merge(options)
88
+ end
89
+
90
+ # The session id the host named, or a fresh one, frozen: a host's
91
+ # string is copied first, so the state holds nothing the host can
92
+ # change and the host's string is never frozen in place.
93
+ #
94
+ # @raise [TypeError] when it is neither nil nor a String
95
+ def session_id(session_id)
96
+ return -SessionId.mint if session_id.nil?
97
+ return -session_id if session_id.is_a?(String)
98
+
99
+ raise TypeError, "a session id is a String, got #{session_id.class}"
100
+ end
101
+
102
+ # The round budget, after checking it.
103
+ def budget(budget)
104
+ return budget if budget == :infinity || (budget.is_a?(Integer) && budget.positive?)
105
+
106
+ raise ArgumentError, "max_macrostep_rounds is a positive Integer or :infinity, got #{budget.inspect}"
107
+ end
108
+ end
109
+ end
110
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Statifier
4
+ module Driver
5
+ # A send of a registered type handed to the host's processor, and a send
6
+ # that processor could not deliver, as the reference's session hands a
7
+ # send to its processor's `deliver/3` and writes a failed send in
8
+ # `Statifier.Session.failed_send/3`. Each function answers
9
+ # `[work, deferred effects]`, as {Routing}'s do.
10
+ #
11
+ # @api private
12
+ module Delivery
13
+ module_function
14
+
15
+ # A registered type's send handed to its processor with the event a
16
+ # delivery would carry; a delayed one is held under its send id, so a
17
+ # `<cancel>` of that id reaches the processor. A {DeliveryFailure} the
18
+ # processor answers fails the send here, at its place in the call, as
19
+ # the reference raises the failure its processor plans in `deliver/3`.
20
+ # The failure counts against the call's budget as an undelayed send
21
+ # onto the internal queue does, so a chart that sends again on every
22
+ # failure halts rather than running on within the call.
23
+ def hand_off(work, send)
24
+ work = hold(work, send) if send.is_a?(Effect::SendDelayed)
25
+ event = Routing.delivered_event(send, work.state.session_id)
26
+ answered = work.processors.fetch(send.type).deliver(send, event, work.context)
27
+ return [work, []] unless answered.is_a?(DeliveryFailure)
28
+
29
+ Routing.counted(work) { failed(_1, send) }
30
+ end
31
+
32
+ # The work with the send's type holding a delayed send under its id.
33
+ def hold(work, send)
34
+ types = (work.state.held_sends.fetch(send.send_id, []) | [send.type]).sort.freeze
35
+ work.with_state(held_sends: work.state.held_sends.merge(send.send_id => types).freeze)
36
+ end
37
+
38
+ # A send its processor could not deliver, as the reference's
39
+ # `Statifier.Session.failed_send/3` writes it: `error.communication`
40
+ # on the internal queue, its origin the send's content and its
41
+ # `sendid` the send's id whether or not its author named it.
42
+ def failed(work, send) = Routing.communication_error(work, send, sendid: send.send_id)
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,129 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Statifier
4
+ module Driver
5
+ # The invocations a session holds and the child sessions behind them, as
6
+ # the reference's session runs a built-in `scxml` invocation
7
+ # (`Statifier.Session`, `Statifier.Session.Invocations` and
8
+ # `Statifier.Invoke.Source` in statifier-ex), with a child's {State}
9
+ # held in its parent's {Invocation} in place of a process. {Driver} says
10
+ # what each does; what passes between a parent and its child is
11
+ # {Mailbox}'s.
12
+ #
13
+ # A child runs as a work of its own ({Mailbox.within}): it starts on its
14
+ # parent's clock and timer sequence, and its state, the timer sequence
15
+ # it leaves, its effects and what it sent its parent go back to the
16
+ # parent once it has run.
17
+ #
18
+ # @api private
19
+ module Invocations
20
+ # The fields of an error an invocation raises: no data and no send id.
21
+ NO_FIELDS = { data: Predicator::UNDEFINED, sendid: nil }.freeze
22
+
23
+ module_function
24
+
25
+ # One invocation effect acted on: `[work, deferred effects]`.
26
+ def perform(work, effect)
27
+ case effect
28
+ when Effect::Invoke then start(work, effect)
29
+ when Effect::CancelInvoke then [cancel(work, effect.invoke_id), []]
30
+ when Effect::Autoforward then [Mailbox.deliver(work, effect.invoke_id, effect.event), []]
31
+ else [work, []]
32
+ end
33
+ end
34
+
35
+ # An {Effect::Invoke}: a type other than the SCXML type raises
36
+ # `error.execution`; content that is not markup, or does not compile,
37
+ # raises `error.communication`; otherwise the child is started.
38
+ # `[work, deferred effects]`.
39
+ def start(work, effect)
40
+ origin = { kind: :invoke, state_index: effect.state_index, invoke_index: effect.invoke_index }
41
+ unless Interpreter::Invoke.built_in_type?(effect.type)
42
+ return Routing.deliver_internal(work, :platform, "error.execution", origin, NO_FIELDS)
43
+ end
44
+
45
+ machine = effect.content.is_a?(String) ? compile(effect.content) : nil
46
+ return Routing.deliver_internal(work, :platform, "error.communication", origin, NO_FIELDS) if machine.nil?
47
+
48
+ [launch(work, effect, machine), []]
49
+ end
50
+
51
+ # Inline content markup compiled as the reference's
52
+ # `Statifier.Invoke.Source` compiles it, a root with no namespace
53
+ # accepted; nil when any stage refuses it.
54
+ def compile(source)
55
+ parsed = XML.parse(source)
56
+ return nil unless parsed.ok?
57
+
58
+ lowered = Lowering.lower(parsed.value, source)
59
+ return nil unless lowered.ok?
60
+
61
+ validated = Validator.validate(lowered.value, source, invoke_content_markup: true)
62
+ return nil unless validated.ok?
63
+
64
+ compiled = Compiler.compile(validated.value, warnings: validated.warnings)
65
+ compiled.ok? ? compiled.value : nil
66
+ end
67
+
68
+ # The child started, recorded live under its invoke id, and run to a
69
+ # stable configuration.
70
+ def launch(work, effect, machine)
71
+ child = child_start(work, effect, machine)
72
+ work = work.put_invocation(Invocation.new(invoke_id: effect.invoke_id, autoforward: effect.autoforward == true,
73
+ completed: false, caller_context: effect.caller_context,
74
+ child: child.state))
75
+ Mailbox.within(work, effect.invoke_id) { Run.drain(Run.perform(_1, child.effects)) }
76
+ end
77
+
78
+ # The child's start: its {State} and the effects its start answered,
79
+ # as a {Interpreter::Stepped}. Its session id is its parent's, a dot
80
+ # and the invoke id.
81
+ def child_start(work, effect, machine)
82
+ session_id = -"#{work.state.session_id}.#{effect.invoke_id}"
83
+ started = Interpreter::Loop.initialize(machine, **start_options(work, session_id, effect.params, machine))
84
+ state = Calls.fresh(started.state, session_id, work.state.send_types).with(invoked_as: effect.invoke_id)
85
+ Interpreter::Stepped.new(state:, effects: started.effects)
86
+ end
87
+
88
+ # What the child starts with: its parent's registered types, their
89
+ # `_ioprocessors` entries asked for its own session id, its parent's
90
+ # trace flag, and its datamodel seeded ({seed}).
91
+ def start_options(work, session_id, params, machine)
92
+ types = work.state.send_types
93
+ { session_id:, clock: work.clock, datamodel: seed(params, machine), trace: work.machine_state.trace,
94
+ send_types: (types unless types.empty?), ioprocessors: Calls.entries(work.processors, types, session_id),
95
+ routes: Calls.own_routes(session_id, parent: true) }
96
+ end
97
+
98
+ # The reference's `Invocations.seed_datamodel/2`: the params a root
99
+ # `<data>` of the child names, the rest dropped.
100
+ def seed(params, machine)
101
+ return {} unless params.is_a?(Hash)
102
+
103
+ params.slice(*machine.state(0).data.map { machine.data_elements.fetch(_1).id })
104
+ end
105
+
106
+ # An {Effect::CancelInvoke}: the invocation retired, then its child
107
+ # stopped, its active states' `<onexit>` handlers run, its effects
108
+ # reported and what it sent its parent discarded. An invocation whose
109
+ # child already said it completed is left for its done event, and an
110
+ # id that names nothing live is a no-op.
111
+ def cancel(work, invoke_id)
112
+ invocation = work.state.invocations[invoke_id]
113
+ return work if invocation.nil? || invocation.completed
114
+
115
+ work = work.drop_invocation(invoke_id)
116
+ return work unless invocation.child.machine_state.running
117
+
118
+ Mailbox.absorb(work, stop(work, work.synced(invocation.child).with(cancelled: true)))
119
+ end
120
+
121
+ # The cancelled child's run: the reference's `Interpreter.cancel/1`
122
+ # and the effects it answered acted on.
123
+ def stop(work, child)
124
+ handled = Interpreter::Loop.cancel(child.machine_state)
125
+ Run.perform(Run.begin_call(child, work.processors, work.clock).with_machine(handled.state), handled.effects)
126
+ end
127
+ end
128
+ end
129
+ end
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Statifier
4
+ module Driver
5
+ # What passes between a session and its invoked children, as the
6
+ # reference's session passes it (`Statifier.Session`, its
7
+ # `Statifier.Session.Invocations` table and `Statifier.Invoke.Answer` in
8
+ # statifier-ex): a child run inside its parent's work ({within}), an
9
+ # event delivered to a child, and what a child sends its parent - an
10
+ # event to `#_parent`, the notice that it completed and its donedata -
11
+ # waiting in the parent's mailbox until the parent takes it.
12
+ #
13
+ # @api private
14
+ module Mailbox
15
+ module_function
16
+
17
+ # The child under `invoke_id` run by the block, which takes and
18
+ # answers the child's work, then its state, timer sequence, effects
19
+ # and mail taken back.
20
+ #
21
+ # @return [Work]
22
+ def within(work, invoke_id)
23
+ invocation = work.state.invocations.fetch(invoke_id)
24
+ ran = yield Run.begin_call(work.synced(invocation.child), work.processors, work.clock)
25
+ receive(absorb(work.put_invocation(invocation.with(child: ran.state)), ran), ran.outbox)
26
+ end
27
+
28
+ # The work with what a child's run left it: the timer sequence and the
29
+ # effects.
30
+ def absorb(work, ran) = work.with_state(timer_sequence: ran.state.timer_sequence).report_all(ran.effects)
31
+
32
+ # The work with a child's mail in its mailbox, an undelayed send's
33
+ # counted.
34
+ def receive(work, outbox)
35
+ outbox.reduce(work) do |acc, (mail, counted)|
36
+ acc = acc.with_state(mailbox: (acc.state.mailbox + [mail]).freeze)
37
+ counted ? acc.with(counted: (acc.counted + [mail]).freeze) : acc
38
+ end
39
+ end
40
+
41
+ # A send to `#_parent`: the event, stamped with the invoke id, into
42
+ # the parent's mailbox.
43
+ def to_parent(work, event, counted:)
44
+ event = Frozen.copy(event.with(invokeid: work.state.invoked_as))
45
+ work.post(Mail.new(kind: :event, invoke_id: work.state.invoked_as, payload: event), counted:)
46
+ end
47
+
48
+ # An event for the live child under `invoke_id`: it joins the child's
49
+ # external queue and the child runs to a stable configuration. Nothing
50
+ # reaches an invocation that is not live, or a child that has stopped.
51
+ def deliver(work, invoke_id, event)
52
+ invocation = work.state.invocations[invoke_id]
53
+ return work if invocation.nil? || !invocation.child.machine_state.running
54
+
55
+ within(work, invoke_id) { Run.drain(Routing.enqueue(_1, event, counted: false).stamped) }
56
+ end
57
+
58
+ # A child whose batch stops it tells its parent it completed, ahead of
59
+ # everything the batch sends; a cancelled child does not.
60
+ #
61
+ # @return [Work]
62
+ def announce(work, batch)
63
+ state = work.state
64
+ return work if state.invoked_as.nil? || state.cancelled || batch.none?(Effect::Done)
65
+
66
+ work.post(Mail.new(kind: :completed, invoke_id: state.invoked_as, payload: nil))
67
+ end
68
+
69
+ # A child that stopped on its own sends its parent its donedata; one
70
+ # that was cancelled sends nothing.
71
+ #
72
+ # @return [Work]
73
+ def return_done(work, done)
74
+ state = work.state
75
+ return work if state.invoked_as.nil? || state.cancelled
76
+
77
+ work.post(Mail.new(kind: :done, invoke_id: state.invoked_as, payload: done.donedata))
78
+ end
79
+
80
+ # One mailbox entry. One that carries an event declares the routes as
81
+ # it arrives; an entry whose invocation is no longer live is
82
+ # discarded.
83
+ #
84
+ # @return [Work]
85
+ def take_mail(work, mail)
86
+ work = work.stamped unless mail.kind == :completed
87
+ invocation = work.state.invocations[mail.invoke_id]
88
+ invocation.nil? ? work : open_mail(work, mail, invocation)
89
+ end
90
+
91
+ # A live invocation's entry: a completion notice marks it completed;
92
+ # an event is taken as an external event; and the done event, built
93
+ # here, retires it once taken.
94
+ #
95
+ # @return [Work]
96
+ def open_mail(work, mail, invocation)
97
+ case mail.kind
98
+ when :completed then work.put_invocation(invocation.with(completed: true))
99
+ when :event then Run.take(work, mail.payload)
100
+ else Run.take(work, done_event(work.state.session_id, invocation, mail.payload)).drop_invocation(mail.invoke_id)
101
+ end
102
+ end
103
+
104
+ # The reference's `Invoke.Answer.done/4`: `done.invoke.` and the
105
+ # invoke id, its data the child's donedata, from the parent's own
106
+ # address, carrying the invocation's `caller_context`.
107
+ #
108
+ # @return [Event]
109
+ def done_event(session_id, invocation, donedata)
110
+ Frozen.copy(Event.external(
111
+ "done.invoke.#{invocation.invoke_id}",
112
+ data: donedata, invokeid: invocation.invoke_id,
113
+ origin: Datamodel::SystemVariables.scxml_location(session_id),
114
+ origintype: Datamodel::SystemVariables::SCXML_EVENT_PROCESSOR,
115
+ caller_context: invocation.caller_context
116
+ ))
117
+ end
118
+ end
119
+ end
120
+ end