@coda-console/kernel 0.0.0-stage → 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.
package/sim/host.mjs ADDED
@@ -0,0 +1,256 @@
1
+ import assert from 'node:assert/strict';
2
+ import { Busy, Rejected } from '../kernel.mjs';
3
+
4
+ // The host the kernel's own tests run in the simulated world: one op, `work`, whose input says how the handler behaves.
5
+ // It keeps the runs it leaves working in a table of its own, and its memory is lost on a crash like the kernel's.
6
+
7
+ const TABLES = `
8
+ CREATE TABLE IF NOT EXISTS host_held (from_addr TEXT NOT NULL, id TEXT NOT NULL, addr TEXT NOT NULL, goal TEXT NOT NULL,
9
+ throws INTEGER NOT NULL DEFAULT 0, settles INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (from_addr, id));`;
10
+
11
+ const key = (from, id) => `${from}/${id}`;
12
+
13
+ /** What a run's handler may report while it works, in turn (C6). */
14
+ export const CHANGES = [
15
+ { progress: { step: 1 } },
16
+ { status: 'input-required', asking: { question: 1 } },
17
+ { status: 'input-required', asking: { question: 1 }, progress: { step: 2 } },
18
+ { status: 'input-required', asking: { question: 2 } },
19
+ { status: 'working' },
20
+ ];
21
+
22
+ /**
23
+ * The setup of the test host: every address takes `work` and its answers, and every address hears its statuses and
24
+ * results. Returns the host, which is also `node.host`.
25
+ * @param {{ kernel: object, node: import('./world.mjs').SimNode, world: import('./world.mjs').World }} context
26
+ */
27
+ export function host(context) {
28
+ const it = new TestHost(context);
29
+ for (const address of context.node.addresses) {
30
+ context.kernel.handle(address, 'work', { accept: call => it.accept(call), cancel: run => it.cancel(run), answer: (run, given) => it.answer(run, given) });
31
+ context.kernel.results(address, { status: call => it.status(call), result: call => it.result(call), late: call => it.late(call) });
32
+ }
33
+ return it;
34
+ }
35
+
36
+ /**
37
+ * The runs the test host of `node` left working, in the order it took them. A test or a check reads them straight from
38
+ * the database, whether the node is up or not; the host itself never needs the list.
39
+ * @param {import('./world.mjs').SimNode} node
40
+ */
41
+ export function held(node) {
42
+ return node.q('SELECT from_addr, id, addr, goal FROM host_held ORDER BY rowid').all();
43
+ }
44
+
45
+ /**
46
+ * Finishes every run the test hosts of the world hold, as quiesce() asks: true while there was one.
47
+ * @param {import('./world.mjs').World} world
48
+ */
49
+ export function advance(world) {
50
+ let moved = false;
51
+ for (const node of world.nodes)
52
+ if (node.up && node.host instanceof TestHost)
53
+ for (const row of held(node)) {
54
+ moved = true;
55
+ world.act(node, kernel => node.host.finish(kernel, row));
56
+ }
57
+ return moved;
58
+ }
59
+
60
+ /** @type {WeakSet<import('./world.mjs').World>} worlds that run the test host's checks */
61
+ const watched = new WeakSet();
62
+
63
+ // The test host's own check after every step: what it scans again on start (T4) is what it keeps working.
64
+ function watch(world) {
65
+ if (watched.has(world)) return;
66
+ watched.add(world);
67
+ world.onStep(() => {
68
+ for (const node of world.nodes) {
69
+ if (!node.up || !(node.host instanceof TestHost)) continue;
70
+ const runs = node.addresses.flatMap(address => node.kernel.runs({ address })).map(run => key(run.from, run.id));
71
+ assert.deepEqual(runs.sort(), held(node).map(row => key(row.from_addr, row.id)).sort(), `runs() of ${node.name} are not the runs its host keeps`);
72
+ }
73
+ });
74
+ }
75
+
76
+ /** The test host of one generation of an instance. It reaches its database only through the generation's `node.db`. */
77
+ export class TestHost {
78
+ busy = new Map();
79
+ thrown = new Map();
80
+ statements = new Map();
81
+
82
+ /** @param {{ kernel: object, node: import('./world.mjs').SimNode, world: import('./world.mjs').World }} context */
83
+ constructor({ kernel, node, world }) {
84
+ this.kernel = kernel;
85
+ this.node = node;
86
+ this.world = world;
87
+ node.db.exec(TABLES);
88
+ node.host = this;
89
+ watch(world);
90
+ }
91
+
92
+ q(sql) {
93
+ let statement = this.statements.get(sql);
94
+ if (!statement) this.statements.set(sql, statement = this.node.db.prepare(sql));
95
+ return statement;
96
+ }
97
+
98
+ /**
99
+ * @param {{ from: string, id: string, to: string,
100
+ * input: { mode: string, n?: number, goal?: string, kids?: object[], cancelThrows?: boolean, cancelSettles?: boolean } }} call
101
+ */
102
+ accept(call) {
103
+ const { mode } = call.input;
104
+ if (mode === 'busy' && !this.world.calm) {
105
+ const times = this.busy.get(key(call.from, call.id)) ?? 0;
106
+ if (times < 2) {
107
+ this.busy.set(key(call.from, call.id), times + 1);
108
+ throw new Busy();
109
+ }
110
+ }
111
+ switch (mode) {
112
+ case 'fail':
113
+ return { outcome: 'failed', error: { code: 'broke', message: 'it broke', effects: 'some', retryAfterMs: 50 } };
114
+ case 'parent':
115
+ this.spawn({ from: call.from, id: call.id }, call.to, call.input.kids);
116
+ // falls through: a parent is held like any other run
117
+ case 'hold':
118
+ this.q('INSERT INTO host_held (from_addr, id, addr, goal, throws, settles) VALUES (?, ?, ?, ?, ?, ?)')
119
+ .run(call.from, call.id, call.to, call.input.goal ?? 'completed', call.input.cancelThrows ? 1 : 0, call.input.cancelSettles ? 1 : 0);
120
+ return undefined;
121
+ case 'reject':
122
+ throw new Rejected('refused', 'not this one');
123
+ case 'throw':
124
+ throw new Error('handler blew up');
125
+ case 'hasty':
126
+ // An outcome at once from a run that awaits a call it made is a handler_error (C9).
127
+ this.spawn({ from: call.from, id: call.id }, call.to, call.input.kids);
128
+ if (call.input.kids?.some(kid => kid.awaited)) this.world.count('accept gave an outcome while awaiting');
129
+ // falls through
130
+ default:
131
+ return { outcome: 'completed', result: { by: call.to, n: call.input.n } };
132
+ }
133
+ }
134
+
135
+ /**
136
+ * A run makes calls while it works (C9).
137
+ * @param {{ from: string, id: string }} run
138
+ * @param {string} address the run's own address
139
+ * @param {Array<{ to: string, mode: string, goal?: string, awaited: boolean }>} kids
140
+ */
141
+ spawn(run, address, kids = []) {
142
+ if (kids.length > 0 && run.id.includes('>')) this.world.count('calls three levels down');
143
+ kids.forEach(({ to, mode, goal, awaited, kids, cancelThrows, cancelSettles, delayMs }, index) => {
144
+ const call = { from: address, id: `${run.from}:${run.id}>${index}`, to, op: 'work', input: { mode, goal, kids, cancelThrows, cancelSettles }, by: run, awaited };
145
+ if (delayMs === undefined) this.kernel.call(call);
146
+ else this.kernel.after({ ...call, delayMs });
147
+ });
148
+ }
149
+
150
+ /** A host starts a root run that makes calls, and keeps it working (2). */
151
+ root(kernel, { address, id, goal, kids }) {
152
+ kernel.root({ address, op: 'work', id, parent: { started: 'by the host' } });
153
+ this.q('INSERT INTO host_held (from_addr, id, addr, goal) VALUES (?, ?, ?, ?)').run(address, id, address, goal);
154
+ this.spawn({ from: address, id }, address, kids);
155
+ }
156
+
157
+ /** @param {{ from: string, id: string, rev: number, memo?: { statusThrows?: boolean } }} call */
158
+ status(call) {
159
+ if (!this.world.calm && call.memo?.statusThrows) throw new Error('status handler blew up');
160
+ }
161
+
162
+ /** @param {{ from: string, id: string, outcome: string, memo?: { resultThrows?: number } }} call */
163
+ result(call) {
164
+ this.heard(call, 'result handler blew up');
165
+ }
166
+
167
+ /** An abandoned call's own outcome came after all (C12). It throws as often as `result` would. */
168
+ late(call) {
169
+ this.world.count('late outcome');
170
+ this.heard(call, 'late handler blew up');
171
+ }
172
+
173
+ heard(call, message) {
174
+ const thrown = this.thrown.get(key(call.from, call.id)) ?? 0;
175
+ if (!this.world.calm && thrown < (call.memo?.resultThrows ?? 0)) {
176
+ this.thrown.set(key(call.from, call.id), thrown + 1);
177
+ throw new Error(message);
178
+ }
179
+ }
180
+
181
+ /**
182
+ * The caller asked to cancel a run this host keeps working: the host means to end it canceled. While the world is not
183
+ * calm, a run taken with `cancelThrows` has a cancel function that throws instead (C7, T6). A run taken with
184
+ * `cancelSettles` ends canceled at once, unless it awaits a call it made.
185
+ * @param {{ from: string, id: string, to: string }} run
186
+ */
187
+ cancel(run) {
188
+ const row = this.q('SELECT throws, settles FROM host_held WHERE from_addr = ? AND id = ?').get(run.from, run.id);
189
+ if (!this.world.calm && row.throws === 1) {
190
+ this.world.count('cancel function threw');
191
+ throw new Error('cancel handler blew up');
192
+ }
193
+ if (row.settles === 1) {
194
+ try {
195
+ this.kernel.settle(run, { outcome: 'canceled' });
196
+ this.q('DELETE FROM host_held WHERE from_addr = ? AND id = ?').run(run.from, run.id);
197
+ this.world.count('cancel function settled at once');
198
+ return;
199
+ } catch (error) {
200
+ if (!/still awaits/.test(error.message)) throw error;
201
+ }
202
+ }
203
+ this.q("UPDATE host_held SET goal = 'canceled' WHERE from_addr = ? AND id = ?").run(run.from, run.id);
204
+ }
205
+
206
+ /**
207
+ * A run waiting for input takes an answer (C8). The answer says what the host does with it: takes it, refuses it,
208
+ * throws, takes it and asks again, or takes it and ends the run, unless the run awaits a call it made.
209
+ * @param {{ from: string, id: string, to: string }} run
210
+ * @param {{ from: string, id: string, asked: number, answer: { mode: string } }} given
211
+ */
212
+ answer(run, { asked, answer }) {
213
+ this.world.count(`answer: to its question (${answer.mode})`);
214
+ switch (answer.mode) {
215
+ case 'refuse':
216
+ throw new Rejected('not_this', 'not this answer');
217
+ case 'throw':
218
+ throw new Error('answer handler blew up');
219
+ case 'ask':
220
+ this.kernel.update(run, { status: 'input-required', asking: { question: asked + 1 } });
221
+ break;
222
+ case 'settle':
223
+ this.finish(this.kernel, this.q('SELECT from_addr, id, addr, goal FROM host_held WHERE from_addr = ? AND id = ?').get(run.from, run.id));
224
+ }
225
+ }
226
+
227
+ /** The host reports how a run it left working is going. */
228
+ nudge(kernel, { from_addr, id }, change) {
229
+ kernel.update({ from: from_addr, id }, change);
230
+ }
231
+
232
+ /**
233
+ * The host finishes a run it left working. The kernel refuses while the run awaits a call it made (C9): then the host
234
+ * waits for another turn, or with `detach` stops waiting for those calls and finishes now. Only a completed run gives a
235
+ * progress of its own; the others end with their last one.
236
+ */
237
+ finish(kernel, { from_addr, id, addr, goal }, { detach = false } = {}) {
238
+ const run = { from: from_addr, id };
239
+ const outcome = goal === 'failed'
240
+ ? { outcome: 'failed', error: { code: 'gave_up', message: 'gave up', effects: 'unknown' } }
241
+ : goal === 'canceled' ? { outcome: 'canceled' } : { outcome: 'completed', result: { late: true } };
242
+ const settle = () => kernel.settle(run, outcome, goal === 'completed' ? { progress: { step: 3 } } : {});
243
+ try {
244
+ settle();
245
+ } catch (error) {
246
+ if (!/still awaits/.test(error.message)) throw error;
247
+ const { calls, timers } = kernel.pending({ address: addr });
248
+ const mine = calls.concat(timers).filter(item => item.awaited && item.by?.from === from_addr && item.by?.id === id);
249
+ if (mine.some(item => item.state === 'scheduled')) this.world.count('a timer it awaits holds a run back');
250
+ if (!detach) return;
251
+ for (const item of mine) kernel.detach(item);
252
+ settle();
253
+ }
254
+ this.q('DELETE FROM host_held WHERE from_addr = ? AND id = ?').run(from_addr, id);
255
+ }
256
+ }