@coda-console/kernel 0.0.0 → 0.2.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/README.md +17 -2
- package/docs/design.md +416 -0
- package/kernel.mjs +1 -0
- package/package.json +20 -3
- package/sim/host.mjs +256 -0
- package/sim/scenarios.mjs +484 -0
- package/sim/solo.mjs +47 -0
- package/sim/world.mjs +922 -0
- package/src/kernel.mjs +1038 -0
- package/src/schema.mjs +169 -0
- package/src/transaction.mjs +75 -0
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
|
+
}
|