@elaraai/east-node-cli 1.0.77 → 1.0.78
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 +126 -2
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +151 -18
- package/dist/cli.js.map +1 -1
- package/dist/emit-writer.d.ts +74 -0
- package/dist/emit-writer.d.ts.map +1 -0
- package/dist/emit-writer.js +159 -0
- package/dist/emit-writer.js.map +1 -0
- package/dist/merge.d.ts +48 -0
- package/dist/merge.d.ts.map +1 -0
- package/dist/merge.js +342 -0
- package/dist/merge.js.map +1 -0
- package/dist/runner.d.ts +53 -3
- package/dist/runner.d.ts.map +1 -1
- package/dist/runner.js +145 -244
- package/dist/runner.js.map +1 -1
- package/dist/runner.spec.js +559 -63
- package/dist/runner.spec.js.map +1 -1
- package/package.json +3 -3
package/dist/runner.spec.js
CHANGED
|
@@ -9,13 +9,39 @@
|
|
|
9
9
|
*/
|
|
10
10
|
import { describe, it, beforeEach, afterEach } from 'node:test';
|
|
11
11
|
import assert from 'node:assert/strict';
|
|
12
|
-
import { existsSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
12
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
13
13
|
import { tmpdir } from 'node:os';
|
|
14
14
|
import { join } from 'node:path';
|
|
15
|
-
import { spawnSync } from 'node:child_process';
|
|
15
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
16
16
|
import { fileURLToPath } from 'node:url';
|
|
17
|
-
import { ArrayType, DictType, FunctionType, IntegerType, NullType, SetType, StringType, StructType, East, SortedMap, compareFor, decodeBeast2For, encodeBeast2PagedFor, encodeEastIR, openBeast2PagesFor, } from '@elaraai/east';
|
|
17
|
+
import { ArrayType, DictType, FloatType, FunctionType, IntegerType, NullType, OptionType, SetType, StringType, StructType, East, SortedMap, SortedSet, compareFor, decodeBeast2For, encodeBeast2For, encodeBeast2PagedFor, toEastTypeValue, encodeEastIR, none, openBeast2PagesFor, some, spliceBeast2, } from '@elaraai/east';
|
|
18
18
|
import { runProgram, lazyThreshold } from './runner.js';
|
|
19
|
+
import { mergeBlobs } from './merge.js';
|
|
20
|
+
import { EmitFileWriter } from './emit-writer.js';
|
|
21
|
+
/** Runs `run` with console.error captured; returns what it printed. */
|
|
22
|
+
async function stderrOf(run) {
|
|
23
|
+
const lines = [];
|
|
24
|
+
const original = console.error;
|
|
25
|
+
console.error = (...args) => { lines.push(args.map(String).join(' ')); };
|
|
26
|
+
try {
|
|
27
|
+
await run();
|
|
28
|
+
}
|
|
29
|
+
finally {
|
|
30
|
+
console.error = original;
|
|
31
|
+
}
|
|
32
|
+
return lines.join('\n');
|
|
33
|
+
}
|
|
34
|
+
/** `promise`'s value, or `undefined` once `ms` pass first — a bounded wait. */
|
|
35
|
+
async function within(promise, ms) {
|
|
36
|
+
let timer;
|
|
37
|
+
const timeout = new Promise((resolve) => { timer = setTimeout(() => resolve(undefined), ms); });
|
|
38
|
+
try {
|
|
39
|
+
return await Promise.race([promise, timeout]);
|
|
40
|
+
}
|
|
41
|
+
finally {
|
|
42
|
+
clearTimeout(timer);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
19
45
|
describe('runner output encoding', () => {
|
|
20
46
|
let tempDir;
|
|
21
47
|
beforeEach(() => {
|
|
@@ -96,24 +122,12 @@ describe('runner streaming execution', () => {
|
|
|
96
122
|
});
|
|
97
123
|
});
|
|
98
124
|
const outputPath = join(tempDir, 'output.beast2');
|
|
99
|
-
await runProgram(writeIr(fn), [], [], [inputPath], outputPath, { emit: 'array',
|
|
125
|
+
await runProgram(writeIr(fn), [], [], [inputPath], outputPath, { emit: 'array', streamInputs: [0] });
|
|
100
126
|
const decoded = decodeBeast2For(AT)(new Uint8Array(readFileSync(outputPath)));
|
|
101
127
|
assert.equal(decoded.length, 2500);
|
|
102
128
|
assert.equal(decoded[0], 0n);
|
|
103
129
|
assert.equal(decoded[2499], (2499n * 2500n) / 2n);
|
|
104
130
|
});
|
|
105
|
-
/** The 0..count keys in a deterministic Fisher-Yates shuffle, so the
|
|
106
|
-
* disorder the sink must absorb is stable across runs. */
|
|
107
|
-
function shuffledKeys(count) {
|
|
108
|
-
const keys = Array.from({ length: count }, (_, i) => BigInt(i));
|
|
109
|
-
let seed = 12345;
|
|
110
|
-
const rnd = () => (seed = (seed * 48271) % 2147483647) / 2147483647;
|
|
111
|
-
for (let i = keys.length - 1; i > 0; i--) {
|
|
112
|
-
const j = Math.floor(rnd() * (i + 1));
|
|
113
|
-
[keys[i], keys[j]] = [keys[j], keys[i]];
|
|
114
|
-
}
|
|
115
|
-
return keys;
|
|
116
|
-
}
|
|
117
131
|
/** A dict producer emitting `row-${i}` for each key in the given order. */
|
|
118
132
|
function dictEmitter(order) {
|
|
119
133
|
const emitType = FunctionType([IntegerType, StringType], NullType);
|
|
@@ -123,55 +137,32 @@ describe('runner streaming execution', () => {
|
|
|
123
137
|
});
|
|
124
138
|
});
|
|
125
139
|
}
|
|
126
|
-
it('
|
|
127
|
-
const
|
|
128
|
-
await runProgram(writeIr(dictEmitter(shuffledKeys(1000))), [], [], [], shuffledPath, { emit: 'dict' });
|
|
129
|
-
const orderedPath = join(tempDir, 'ordered.beast2');
|
|
140
|
+
it('an ascending dict emit writes the canonical blob, segmented and indexed', async () => {
|
|
141
|
+
const outputPath = join(tempDir, 'ordered.beast2');
|
|
130
142
|
const ascending = Array.from({ length: 1000 }, (_, i) => BigInt(i));
|
|
131
|
-
await runProgram(writeIr(dictEmitter(ascending)), [], [], [],
|
|
132
|
-
const
|
|
133
|
-
assert.deepEqual(shuffled, new Uint8Array(readFileSync(orderedPath)), 'out-of-order emission must produce the byte-identical canonical blob');
|
|
143
|
+
await runProgram(writeIr(dictEmitter(ascending)), [], [], [], outputPath, { emit: 'dict' });
|
|
144
|
+
const output = new Uint8Array(readFileSync(outputPath));
|
|
134
145
|
const DT = DictType(IntegerType, StringType);
|
|
135
|
-
const decoded = decodeBeast2For(DT)(
|
|
146
|
+
const decoded = decodeBeast2For(DT)(output);
|
|
136
147
|
assert.equal(decoded.size, 1000);
|
|
137
148
|
assert.equal(decoded.get(42n), 'row-42');
|
|
138
|
-
assert.ok(openBeast2PagesFor(DT)(
|
|
139
|
-
assert.ok(!existsSync(`${shuffledPath}.run0`), 'spill runs must be cleaned up');
|
|
149
|
+
assert.ok(openBeast2PagesFor(DT)(output).selfContained);
|
|
140
150
|
});
|
|
141
|
-
it('a
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
const ascending = Array.from({ length: 300 }, (_, i) => BigInt(i));
|
|
149
|
-
await runProgram(writeIr(dictEmitter(ascending)), [], [], [], orderedPath, { emit: 'dict' });
|
|
150
|
-
assert.deepEqual(new Uint8Array(readFileSync(shuffledPath)), new Uint8Array(readFileSync(orderedPath)));
|
|
151
|
-
assert.ok(!existsSync(`${shuffledPath}.run1`), 'spill runs must be cleaned up');
|
|
152
|
-
}
|
|
153
|
-
finally {
|
|
154
|
-
if (saved === undefined)
|
|
155
|
-
delete process.env.EAST_EMIT_RUN_ELEMENTS;
|
|
156
|
-
else
|
|
157
|
-
process.env.EAST_EMIT_RUN_ELEMENTS = saved;
|
|
158
|
-
}
|
|
151
|
+
it('a dict emit rejects an out-of-order key with the ascending contract\'s error (#770)', async () => {
|
|
152
|
+
// Set/Dict emissions must ascend in East order: the sink writes one pass
|
|
153
|
+
// and never buffers, so a key below the previous one is the error — in
|
|
154
|
+
// the same words on every runner — and nothing is reported on stderr on
|
|
155
|
+
// the way there.
|
|
156
|
+
const err = await stderrOf(() => assert.rejects(runProgram(writeIr(dictEmitter([2n, 1n])), [], [], [], join(tempDir, 'disorder.beast2'), { emit: 'dict' }), { message: 'beast2 v5: Dict key emitted out of order: 1 after 2 — Set/Dict emissions must ascend in East order' }));
|
|
157
|
+
assert.equal(err, '');
|
|
159
158
|
});
|
|
160
|
-
it('a duplicate dict key is a hard error
|
|
159
|
+
it('a duplicate dict key is a hard error', async () => {
|
|
161
160
|
const emitType = FunctionType([IntegerType, StringType], NullType);
|
|
162
161
|
const adjacent = East.function([emitType], NullType, ($, emit) => {
|
|
163
162
|
$(emit(1n, 'a'));
|
|
164
163
|
$(emit(1n, 'b'));
|
|
165
164
|
});
|
|
166
|
-
await assert.rejects(runProgram(writeIr(adjacent), [], [], [], join(tempDir, 'dup.beast2'), { emit: 'dict' }),
|
|
167
|
-
// An inversion first (buffered mode), so the duplicate is only visible
|
|
168
|
-
// to the finalize-time merge.
|
|
169
|
-
const crossRun = East.function([emitType], NullType, ($, emit) => {
|
|
170
|
-
$(emit(5n, 'x'));
|
|
171
|
-
$(emit(3n, 'y'));
|
|
172
|
-
$(emit(5n, 'dup'));
|
|
173
|
-
});
|
|
174
|
-
await assert.rejects(runProgram(writeIr(crossRun), [], [], [], join(tempDir, 'dup2.beast2'), { emit: 'dict' }), /duplicate Dict key emitted/);
|
|
165
|
+
await assert.rejects(runProgram(writeIr(adjacent), [], [], [], join(tempDir, 'dup.beast2'), { emit: 'dict' }), { message: 'beast2 v5: duplicate Dict key emitted: 1 — Dict keys must be unique' });
|
|
175
166
|
});
|
|
176
167
|
it('a lazy dict input serves keyed reads without a whole decode', async () => {
|
|
177
168
|
const DT = DictType(IntegerType, StringType);
|
|
@@ -258,26 +249,531 @@ describe('runner streaming execution', () => {
|
|
|
258
249
|
await assert.rejects(runProgram(writeIr(fn), [], [], [], join(tempDir, 'out.json'), { emit: 'array' }), /--emit requires a \.beast2 output file \(-o\)/);
|
|
259
250
|
});
|
|
260
251
|
it('refuses --emit on a zero-parameter function with the emit-capability error', async () => {
|
|
261
|
-
|
|
262
|
-
|
|
252
|
+
// The one sentence east-c and east-py give for the same shape — a
|
|
253
|
+
// zero-parameter function has no trailing parameter to be the capability,
|
|
254
|
+
// and so does one whose trailing parameter is not a function.
|
|
255
|
+
const message = "--emit requires the function's trailing parameter to be the emit capability (a function type)";
|
|
256
|
+
await assert.rejects(runProgram(writeIr(East.function([], IntegerType, (_$) => 1n)), [], [], [], join(tempDir, 'out.beast2'), { emit: 'array' }), { message });
|
|
257
|
+
await assert.rejects(runProgram(writeIr(East.function([IntegerType], IntegerType, (_$, n) => n)), [], [], [], join(tempDir, 'out.beast2'), { emit: 'array' }), { message });
|
|
263
258
|
});
|
|
264
|
-
it('a set emit
|
|
259
|
+
it('a set emit rejects an out-of-order element and names duplicates with the element noun', async () => {
|
|
265
260
|
const emitType = FunctionType([IntegerType], NullType);
|
|
266
261
|
const disordered = East.function([emitType], NullType, ($, emit) => {
|
|
267
262
|
$(emit(2n));
|
|
268
263
|
$(emit(1n));
|
|
269
264
|
$(emit(3n));
|
|
270
265
|
});
|
|
271
|
-
|
|
272
|
-
await runProgram(writeIr(disordered), [], [], [], outputPath, { emit: 'set' });
|
|
273
|
-
const decoded = decodeBeast2For(SetType(IntegerType))(new Uint8Array(readFileSync(outputPath)));
|
|
274
|
-
assert.deepEqual([...decoded], [1n, 2n, 3n]);
|
|
266
|
+
await assert.rejects(runProgram(writeIr(disordered), [], [], [], join(tempDir, 'set.beast2'), { emit: 'set' }), { message: 'beast2 v5: Set element emitted out of order: 1 after 2 — Set/Dict emissions must ascend in East order' });
|
|
275
267
|
const dup = East.function([emitType], NullType, ($, emit) => {
|
|
276
|
-
$(emit(2n));
|
|
277
268
|
$(emit(1n));
|
|
278
269
|
$(emit(2n));
|
|
270
|
+
$(emit(2n));
|
|
271
|
+
});
|
|
272
|
+
await assert.rejects(runProgram(writeIr(dup), [], [], [], join(tempDir, 'dupset.beast2'), { emit: 'set' }), { message: 'beast2 v5: duplicate Set element emitted: 2 — Set elements must be unique' });
|
|
273
|
+
});
|
|
274
|
+
});
|
|
275
|
+
describe('folding emit, the blob merge and the stdin lifeline (#770)', () => {
|
|
276
|
+
let tempDir;
|
|
277
|
+
beforeEach(() => {
|
|
278
|
+
tempDir = mkdtempSync(join(tmpdir(), 'east-node-fold-'));
|
|
279
|
+
});
|
|
280
|
+
afterEach(() => {
|
|
281
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
282
|
+
});
|
|
283
|
+
function writeIr(name, fn) {
|
|
284
|
+
const irPath = join(tempDir, name);
|
|
285
|
+
writeFileSync(irPath, encodeEastIR(fn.toIR()));
|
|
286
|
+
return irPath;
|
|
287
|
+
}
|
|
288
|
+
const PairType = StructType({ key: IntegerType, value: StringType });
|
|
289
|
+
const emitPairType = FunctionType([IntegerType, StringType], NullType);
|
|
290
|
+
const emitKeyType = FunctionType([IntegerType], NullType);
|
|
291
|
+
const ascendingBigints = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
|
|
292
|
+
const DT = DictType(IntegerType, StringType);
|
|
293
|
+
const ST = SetType(IntegerType);
|
|
294
|
+
/** The fold contract's emission sequence, as keys — the sequence
|
|
295
|
+
* generate_fixtures.mjs writes for east-c and east-py: 0..1199 in order
|
|
296
|
+
* with adjacent duplicates, every third key twice, and key 999 — the last
|
|
297
|
+
* entry of a full 1000-element batch — four times. */
|
|
298
|
+
function foldSequence() {
|
|
299
|
+
const keys = [];
|
|
300
|
+
for (let k = 0; k < 1200; k++) {
|
|
301
|
+
const copies = 1 + (k % 3 === 0 ? 1 : 0) + (k % 1000 === 999 ? 2 : 0);
|
|
302
|
+
for (let c = 0; c < copies; c++)
|
|
303
|
+
keys.push(BigInt(k));
|
|
304
|
+
}
|
|
305
|
+
return keys;
|
|
306
|
+
}
|
|
307
|
+
/** A dict producer emitting the given (key, value) pairs in order. */
|
|
308
|
+
function pairEmitter(pairs) {
|
|
309
|
+
return East.function([emitPairType], NullType, ($, emit) => {
|
|
310
|
+
$.for($.const(pairs, ArrayType(PairType)), ($, pair) => {
|
|
311
|
+
$(emit(pair.key, pair.value));
|
|
312
|
+
});
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
/** A set producer emitting the given keys in order. */
|
|
316
|
+
function keyEmitter(keys) {
|
|
317
|
+
return East.function([emitKeyType], NullType, ($, emit) => {
|
|
318
|
+
$.for($.const(keys, ArrayType(IntegerType)), ($, key) => {
|
|
319
|
+
$(emit(key));
|
|
320
|
+
});
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
/** The concatenating `(Integer, String, String) -> String` fold. */
|
|
324
|
+
function writeConcat() {
|
|
325
|
+
return writeIr('merge.beast2', East.function([IntegerType, StringType, StringType], StringType, (_$, _key, acc, value) => acc.concat(value)));
|
|
326
|
+
}
|
|
327
|
+
it('--merge writes the folded ascending sequence\'s bytes', async () => {
|
|
328
|
+
// The fold is over ADJACENT equal keys, and lands in place — key 999
|
|
329
|
+
// into a full batch's last entry included — so the output is
|
|
330
|
+
// byte-identical to what the flag-less sink writes for the folded
|
|
331
|
+
// sequence.
|
|
332
|
+
const pairs = foldSequence().map((key, i) => ({ key, value: `${i};` }));
|
|
333
|
+
const folded = new Map();
|
|
334
|
+
for (const { key, value } of pairs)
|
|
335
|
+
folded.set(key, (folded.get(key) ?? '') + value);
|
|
336
|
+
const foldedPairs = [...folded].sort(([a], [b]) => ascendingBigints(a, b)).map(([key, value]) => ({ key, value }));
|
|
337
|
+
const expectedPath = join(tempDir, 'expected.beast2');
|
|
338
|
+
await runProgram(writeIr('folded.beast2', pairEmitter(foldedPairs)), [], [], [], expectedPath, { emit: 'dict' });
|
|
339
|
+
const outputPath = join(tempDir, 'output.beast2');
|
|
340
|
+
await runProgram(writeIr('program.beast2', pairEmitter(pairs)), [], [], [], outputPath, { emit: 'dict', merge: writeConcat() });
|
|
341
|
+
assert.deepEqual(new Uint8Array(readFileSync(outputPath)), new Uint8Array(readFileSync(expectedPath)), 'the folded output must be byte-identical to the flag-less sink\'s output for the folded sequence');
|
|
342
|
+
});
|
|
343
|
+
it('--union writes the ascending sequence\'s distinct keys\' bytes', async () => {
|
|
344
|
+
const keys = foldSequence();
|
|
345
|
+
const distinct = [...new Set(keys)].sort(ascendingBigints);
|
|
346
|
+
const expectedPath = join(tempDir, 'expected.beast2');
|
|
347
|
+
await runProgram(writeIr('folded.beast2', keyEmitter(distinct)), [], [], [], expectedPath, { emit: 'set' });
|
|
348
|
+
const outputPath = join(tempDir, 'output.beast2');
|
|
349
|
+
await runProgram(writeIr('program.beast2', keyEmitter(keys)), [], [], [], outputPath, { emit: 'set', union: true });
|
|
350
|
+
assert.deepEqual(new Uint8Array(readFileSync(outputPath)), new Uint8Array(readFileSync(expectedPath)), 'the union output must be byte-identical to the flag-less sink\'s output for the distinct keys');
|
|
351
|
+
});
|
|
352
|
+
it('a fold does not admit an out-of-order key', async () => {
|
|
353
|
+
const pairs = [{ key: 2n, value: 'a' }, { key: 1n, value: 'b' }];
|
|
354
|
+
await assert.rejects(runProgram(writeIr('program.beast2', pairEmitter(pairs)), [], [], [], join(tempDir, 'output.beast2'), { emit: 'dict', merge: writeConcat() }), { message: 'beast2 v5: Dict key emitted out of order: 1 after 2 — Set/Dict emissions must ascend in East order' });
|
|
355
|
+
});
|
|
356
|
+
// ---- The blob merge ---------------------------------------------------
|
|
357
|
+
/** Three sorted Dict inputs whose keys overlap — a = 0..19, b = 10..29,
|
|
358
|
+
* c = {5, 15, 25, 40} — each value naming its input, written by the paged
|
|
359
|
+
* writer in four-entry segments; the fixtures east-c and east-py merge. */
|
|
360
|
+
const mergeKeys = {
|
|
361
|
+
a: Array.from({ length: 20 }, (_, k) => BigInt(k)),
|
|
362
|
+
b: Array.from({ length: 20 }, (_, k) => BigInt(k + 10)),
|
|
363
|
+
c: [5n, 15n, 25n, 40n],
|
|
364
|
+
};
|
|
365
|
+
function writeDictInput(name) {
|
|
366
|
+
const path = join(tempDir, `in_${name}.beast2`);
|
|
367
|
+
writeFileSync(path, encodeBeast2PagedFor(DT, { batchSize: 4 })(new SortedMap(mergeKeys[name].map((k) => [k, `${name}${k}`]), compareFor(IntegerType))));
|
|
368
|
+
return path;
|
|
369
|
+
}
|
|
370
|
+
function writeSetInput(name) {
|
|
371
|
+
const path = join(tempDir, `set_${name}.beast2`);
|
|
372
|
+
writeFileSync(path, encodeBeast2PagedFor(ST, { batchSize: 4 })(new SortedSet(mergeKeys[name], compareFor(IntegerType))));
|
|
373
|
+
return path;
|
|
374
|
+
}
|
|
375
|
+
it('merge writes the fold of overlapping dict inputs byte-identical to the ascending sink', async () => {
|
|
376
|
+
const folded = new Map();
|
|
377
|
+
for (const name of ['a', 'b', 'c']) {
|
|
378
|
+
for (const k of mergeKeys[name])
|
|
379
|
+
folded.set(k, (folded.get(k) ?? '') + `${name}${k}`);
|
|
380
|
+
}
|
|
381
|
+
const foldedPairs = [...folded].sort(([a], [b]) => ascendingBigints(a, b)).map(([key, value]) => ({ key, value }));
|
|
382
|
+
const expectedPath = join(tempDir, 'expected.beast2');
|
|
383
|
+
await runProgram(writeIr('folded.beast2', pairEmitter(foldedPairs)), [], [], [], expectedPath, { emit: 'dict' });
|
|
384
|
+
const outputPath = join(tempDir, 'merged.beast2');
|
|
385
|
+
const inputs = [writeDictInput('a'), writeDictInput('b'), writeDictInput('c')];
|
|
386
|
+
const stats = mergeBlobs(inputs, outputPath, { mergePath: writeConcat() });
|
|
387
|
+
assert.deepEqual(stats, { inputs: 3, entries: 31, folds: 13 });
|
|
388
|
+
assert.deepEqual(new Uint8Array(readFileSync(outputPath)), new Uint8Array(readFileSync(expectedPath)), 'the merge must write exactly what the sink writes for the folded sequence emitted ascending');
|
|
389
|
+
const table = decodeBeast2For(DT)(new Uint8Array(readFileSync(outputPath)));
|
|
390
|
+
assert.equal(table.get(15n), 'a15b15c15');
|
|
391
|
+
assert.equal(table.get(40n), 'c40');
|
|
392
|
+
});
|
|
393
|
+
it('merge --union writes the distinct elements of set inputs byte-identical to the ascending sink', async () => {
|
|
394
|
+
const distinct = [...new Set([...mergeKeys.a, ...mergeKeys.b, ...mergeKeys.c])].sort(ascendingBigints);
|
|
395
|
+
const expectedPath = join(tempDir, 'expected.beast2');
|
|
396
|
+
await runProgram(writeIr('folded.beast2', keyEmitter(distinct)), [], [], [], expectedPath, { emit: 'set' });
|
|
397
|
+
const outputPath = join(tempDir, 'union.beast2');
|
|
398
|
+
const stats = mergeBlobs([writeSetInput('a'), writeSetInput('b'), writeSetInput('c')], outputPath, { union: true });
|
|
399
|
+
assert.deepEqual(stats, { inputs: 3, entries: 31, folds: 13 });
|
|
400
|
+
assert.deepEqual(new Uint8Array(readFileSync(outputPath)), new Uint8Array(readFileSync(expectedPath)));
|
|
401
|
+
assert.equal(openBeast2PagesFor(ST)(new Uint8Array(readFileSync(outputPath))).elementCount, 31);
|
|
402
|
+
});
|
|
403
|
+
it('merge without a fold refuses a shared key and leaves the output unfinalised', () => {
|
|
404
|
+
const outputPath = join(tempDir, 'dup.beast2');
|
|
405
|
+
assert.throws(() => mergeBlobs([writeDictInput('a'), writeDictInput('b')], outputPath), { message: 'beast2 v5: duplicate Dict key emitted: 10 — Dict keys must be unique' });
|
|
406
|
+
assert.throws(() => openBeast2PagesFor(DT)(new Uint8Array(readFileSync(outputPath))), 'the aborted output carries no index');
|
|
407
|
+
});
|
|
408
|
+
it('merge refuses an input of another type, an Array input and a descending input, naming the input', () => {
|
|
409
|
+
const other = join(tempDir, 'other.beast2');
|
|
410
|
+
writeFileSync(other, encodeBeast2PagedFor(DictType(StringType, FloatType))(new SortedMap([['x', 1.5]], compareFor(StringType))));
|
|
411
|
+
assert.throws(() => mergeBlobs([writeDictInput('a'), other], join(tempDir, 'x.beast2'), { mergePath: writeConcat() }), (err) => err.message.startsWith(`merge: input 1 (${other}) has type `) && err.message.endsWith(' (input 0)'));
|
|
412
|
+
const rows = join(tempDir, 'rows.beast2');
|
|
413
|
+
writeFileSync(rows, encodeBeast2PagedFor(ArrayType(IntegerType))([1n, 2n]));
|
|
414
|
+
assert.throws(() => mergeBlobs([rows], join(tempDir, 'array.beast2')), { message: 'merge: inputs must be Set or Dict blobs, got Array' });
|
|
415
|
+
// A high key range spliced before a low one: the reader's canonical-order
|
|
416
|
+
// error, prefixed with the input — the same sentence east-c and east-py give.
|
|
417
|
+
const descending = join(tempDir, 'descending.beast2');
|
|
418
|
+
writeFileSync(descending, spliceBeast2([
|
|
419
|
+
encodeBeast2PagedFor(DT, { batchSize: 2 })(new SortedMap([[1000n, 'x'], [1001n, 'y']], compareFor(IntegerType))),
|
|
420
|
+
encodeBeast2PagedFor(DT, { batchSize: 2 })(new SortedMap([[1n, 'a'], [2n, 'b']], compareFor(IntegerType))),
|
|
421
|
+
]));
|
|
422
|
+
assert.throws(() => mergeBlobs([descending], join(tempDir, 'desc.beast2')), { message: `merge: input 0 (${descending}): beast2 v5: Dict keys are not strictly ascending in East order — the wire must hold the canonical value (corrupt or pre-contract blob)` });
|
|
423
|
+
const missing = join(tempDir, 'missing.beast2');
|
|
424
|
+
assert.throws(() => mergeBlobs([missing], join(tempDir, 'm.beast2')), { message: `merge: input 0 (${missing}): cannot open the file` });
|
|
425
|
+
// A file that is not a blob is refused in the reader's own words — the
|
|
426
|
+
// sentence east-c and east-py give for the same bytes; a directory
|
|
427
|
+
// cannot be opened.
|
|
428
|
+
const empty = join(tempDir, 'empty-file.beast2');
|
|
429
|
+
writeFileSync(empty, '');
|
|
430
|
+
assert.throws(() => mergeBlobs([empty], join(tempDir, 'e.beast2')), { message: `merge: input 0 (${empty}): Data too short for Beast2 format: 0 bytes` });
|
|
431
|
+
const text = join(tempDir, 'text.beast2');
|
|
432
|
+
writeFileSync(text, 'not a blob');
|
|
433
|
+
assert.throws(() => mergeBlobs([writeDictInput('a'), text], join(tempDir, 't.beast2'), { mergePath: writeConcat() }), { message: `merge: input 1 (${text}): Invalid Beast2 magic at offset 0: expected 0x89, got 0x6e` });
|
|
434
|
+
assert.throws(() => mergeBlobs([tempDir], join(tempDir, 'd.beast2')), { message: `merge: input 0 (${tempDir}): cannot open the file` });
|
|
435
|
+
// A blob without the paging index — a whole-value encode, not what a
|
|
436
|
+
// runner writes — is refused in the reader's words, as east-c and
|
|
437
|
+
// east-py refuse it.
|
|
438
|
+
const whole = join(tempDir, 'whole.beast2');
|
|
439
|
+
writeFileSync(whole, encodeBeast2For(DT)(new SortedMap([[1n, 'a']], compareFor(IntegerType))));
|
|
440
|
+
assert.throws(() => mergeBlobs([whole], join(tempDir, 'w.beast2')), { message: `merge: input 0 (${whole}): beast2 v5: blob carries no index — ranged reads need one (write with the index enabled, the default)` });
|
|
441
|
+
});
|
|
442
|
+
it('merge names a fold of the wrong signature and refuses a fold of the wrong kind', () => {
|
|
443
|
+
const wrong = writeIr('wrong.beast2', East.function([StringType, StringType, StringType], StringType, (_$, _key, acc, value) => acc.concat(value)));
|
|
444
|
+
assert.throws(() => mergeBlobs([writeDictInput('a')], join(tempDir, 'w.beast2'), { mergePath: wrong }), (err) => err.message.startsWith('--merge: expected a function (K, V, V) -> V matching the inputs (K = '));
|
|
445
|
+
assert.throws(() => mergeBlobs([writeSetInput('a')], join(tempDir, 'ms.beast2'), { mergePath: writeConcat() }), { message: '--merge applies to Dict inputs only' });
|
|
446
|
+
assert.throws(() => mergeBlobs([writeDictInput('a')], join(tempDir, 'ud.beast2'), { union: true }), { message: '--union applies to Set inputs only' });
|
|
447
|
+
assert.throws(() => mergeBlobs([writeDictInput('a')], join(tempDir, 'both.beast2'), { mergePath: writeConcat(), union: true }), { message: 'merge: --merge and --union are two folds — give one' });
|
|
448
|
+
});
|
|
449
|
+
it('an empty input contributes nothing, and a lone empty input yields the empty collection, indexed', () => {
|
|
450
|
+
const empty = join(tempDir, 'empty.beast2');
|
|
451
|
+
writeFileSync(empty, encodeBeast2PagedFor(DT)(new SortedMap([], compareFor(IntegerType))));
|
|
452
|
+
const withEmpty = join(tempDir, 'with-empty.beast2');
|
|
453
|
+
mergeBlobs([empty, writeDictInput('a')], withEmpty, { mergePath: writeConcat() });
|
|
454
|
+
const alone = join(tempDir, 'alone.beast2');
|
|
455
|
+
mergeBlobs([writeDictInput('a')], alone);
|
|
456
|
+
assert.deepEqual(new Uint8Array(readFileSync(withEmpty)), new Uint8Array(readFileSync(alone)));
|
|
457
|
+
assert.equal(openBeast2PagesFor(DT)(new Uint8Array(readFileSync(alone))).elementCount, 20);
|
|
458
|
+
const emptyOut = join(tempDir, 'empty-out.beast2');
|
|
459
|
+
assert.deepEqual(mergeBlobs([empty], emptyOut), { inputs: 1, entries: 0, folds: 0 });
|
|
460
|
+
assert.equal(openBeast2PagesFor(DT)(new Uint8Array(readFileSync(emptyOut))).elementCount, 0);
|
|
461
|
+
});
|
|
462
|
+
// ---- Key ranges ---------------------------------------------------------
|
|
463
|
+
/** A `--range` blob over Integer keys: `[from, to)`, a `null` bound open. */
|
|
464
|
+
const RangeT = StructType({ from: OptionType(IntegerType), to: OptionType(IntegerType) });
|
|
465
|
+
function writeRange(name, from, to) {
|
|
466
|
+
const path = join(tempDir, `range_${name}.beast2`);
|
|
467
|
+
writeFileSync(path, encodeBeast2For(RangeT)({ from: from === null ? none : some(from), to: to === null ? none : some(to) }));
|
|
468
|
+
return path;
|
|
469
|
+
}
|
|
470
|
+
/** Whether `k` lies in `[from, to)`. */
|
|
471
|
+
const inRange = (k, from, to) => (from === null || k >= from) && (to === null || k < to);
|
|
472
|
+
/** An input holding only its keys in the range — the sliced twin a ranged merge must equal. */
|
|
473
|
+
function writeDictSlice(name, from, to) {
|
|
474
|
+
const path = join(tempDir, `in_${name}_${from}_${to}.beast2`);
|
|
475
|
+
writeFileSync(path, encodeBeast2PagedFor(DT, { batchSize: 4 })(new SortedMap(mergeKeys[name].filter((k) => inRange(k, from, to)).map((k) => [k, `${name}${k}`]), compareFor(IntegerType))));
|
|
476
|
+
return path;
|
|
477
|
+
}
|
|
478
|
+
function writeSetSlice(name, from, to) {
|
|
479
|
+
const path = join(tempDir, `set_${name}_${from}_${to}.beast2`);
|
|
480
|
+
writeFileSync(path, encodeBeast2PagedFor(ST, { batchSize: 4 })(new SortedSet(mergeKeys[name].filter((k) => inRange(k, from, to)), compareFor(IntegerType))));
|
|
481
|
+
return path;
|
|
482
|
+
}
|
|
483
|
+
it('merge --range writes the keys in [from, to) byte-identical to merging inputs that hold only those keys', () => {
|
|
484
|
+
// The inputs are written in four-entry segments, so the bounds fall
|
|
485
|
+
// inside segments, on a fence, before the first key, past the last,
|
|
486
|
+
// and one range holds nothing.
|
|
487
|
+
const cases = [[7n, 22n], [null, 12n], [25n, null], [8n, 16n], [-5n, 3n], [40n, 41n], [30n, 39n], [12n, 12n], [null, null]];
|
|
488
|
+
const inputs = [writeDictInput('a'), writeDictInput('b'), writeDictInput('c')];
|
|
489
|
+
const sets = [writeSetInput('a'), writeSetInput('b'), writeSetInput('c')];
|
|
490
|
+
for (const [from, to] of cases) {
|
|
491
|
+
const label = `[${from}, ${to})`;
|
|
492
|
+
const twin = join(tempDir, `twin_${from}_${to}.beast2`);
|
|
493
|
+
const twinStats = mergeBlobs([writeDictSlice('a', from, to), writeDictSlice('b', from, to), writeDictSlice('c', from, to)], twin, { mergePath: writeConcat() });
|
|
494
|
+
const ranged = join(tempDir, `ranged_${from}_${to}.beast2`);
|
|
495
|
+
const stats = mergeBlobs(inputs, ranged, { mergePath: writeConcat(), rangePath: writeRange(`${from}_${to}`, from, to) });
|
|
496
|
+
assert.deepEqual(stats, twinStats, label);
|
|
497
|
+
assert.deepEqual(new Uint8Array(readFileSync(ranged)), new Uint8Array(readFileSync(twin)), `${label}: the ranged merge must write exactly the sliced twin's bytes`);
|
|
498
|
+
const table = decodeBeast2For(DT)(new Uint8Array(readFileSync(ranged)));
|
|
499
|
+
for (const k of [...mergeKeys.a, ...mergeKeys.b, ...mergeKeys.c])
|
|
500
|
+
assert.equal(table.has(k), inRange(k, from, to), `${label}: key ${k}`);
|
|
501
|
+
const unionTwin = join(tempDir, `union_twin_${from}_${to}.beast2`);
|
|
502
|
+
const unionTwinStats = mergeBlobs([writeSetSlice('a', from, to), writeSetSlice('b', from, to), writeSetSlice('c', from, to)], unionTwin, { union: true });
|
|
503
|
+
const unionRanged = join(tempDir, `union_ranged_${from}_${to}.beast2`);
|
|
504
|
+
assert.deepEqual(mergeBlobs(sets, unionRanged, { union: true, rangePath: writeRange(`u_${from}_${to}`, from, to) }), unionTwinStats, label);
|
|
505
|
+
assert.deepEqual(new Uint8Array(readFileSync(unionRanged)), new Uint8Array(readFileSync(unionTwin)), `${label}: union`);
|
|
506
|
+
}
|
|
507
|
+
// The open range is the whole merge, and [7, 22) folds 15 keys, 11 of them shared.
|
|
508
|
+
const whole = join(tempDir, 'whole.beast2');
|
|
509
|
+
mergeBlobs(inputs, whole, { mergePath: writeConcat() });
|
|
510
|
+
assert.deepEqual(new Uint8Array(readFileSync(join(tempDir, 'ranged_null_null.beast2'))), new Uint8Array(readFileSync(whole)));
|
|
511
|
+
assert.equal(decodeBeast2For(DT)(new Uint8Array(readFileSync(join(tempDir, 'ranged_7_22.beast2')))).get(15n), 'a15b15c15');
|
|
512
|
+
assert.equal(openBeast2PagesFor(DT)(new Uint8Array(readFileSync(join(tempDir, 'ranged_7_22.beast2')))).elementCount, 15);
|
|
513
|
+
assert.equal(openBeast2PagesFor(DT)(new Uint8Array(readFileSync(join(tempDir, 'ranged_30_39.beast2')))).elementCount, 0, 'a range holding nothing writes the empty collection, indexed');
|
|
514
|
+
});
|
|
515
|
+
it('merge --range refuses bounds of another key type, a missing file, an empty file and a file that is not a blob', () => {
|
|
516
|
+
const inputs = [writeDictInput('a'), writeDictInput('b')];
|
|
517
|
+
const mismatch = join(tempDir, 'range_mismatch.beast2');
|
|
518
|
+
writeFileSync(mismatch, encodeBeast2For(StructType({ from: OptionType(StringType), to: OptionType(StringType) }))({ from: none, to: none }));
|
|
519
|
+
assert.throws(() => mergeBlobs(inputs, join(tempDir, 'rm.beast2'), { mergePath: writeConcat(), rangePath: mismatch }), (err) => err.message.startsWith(`merge: --range (${mismatch}) has type `) && err.message.endsWith(" (bounds over the inputs' key type)"));
|
|
520
|
+
const missing = join(tempDir, 'range_missing.beast2');
|
|
521
|
+
assert.throws(() => mergeBlobs(inputs, join(tempDir, 'rm2.beast2'), { mergePath: writeConcat(), rangePath: missing }), { message: `merge: --range (${missing}): cannot open the file` });
|
|
522
|
+
// Not a blob: the reader's own words, as east-c and east-py say them.
|
|
523
|
+
const text = join(tempDir, 'range_text.beast2');
|
|
524
|
+
writeFileSync(text, 'not a blob');
|
|
525
|
+
assert.throws(() => mergeBlobs(inputs, join(tempDir, 'rm3.beast2'), { mergePath: writeConcat(), rangePath: text }), { message: `merge: --range (${text}): Invalid Beast2 magic at offset 0: expected 0x89, got 0x6e` });
|
|
526
|
+
const empty = join(tempDir, 'range_empty.beast2');
|
|
527
|
+
writeFileSync(empty, '');
|
|
528
|
+
assert.throws(() => mergeBlobs(inputs, join(tempDir, 'rm4.beast2'), { mergePath: writeConcat(), rangePath: empty }), { message: `merge: --range (${empty}): Data too short for Beast2 format: 0 bytes` });
|
|
529
|
+
});
|
|
530
|
+
it('the merge command prints its account, takes a range and refuses two folds', () => {
|
|
531
|
+
const bin = fileURLToPath(new URL('../bin/east-node.mjs', import.meta.url));
|
|
532
|
+
const outputPath = join(tempDir, 'cli-merged.beast2');
|
|
533
|
+
const inputs = [writeDictInput('a'), writeDictInput('b'), writeDictInput('c')];
|
|
534
|
+
const result = spawnSync(process.execPath, [bin, 'merge', '--merge', writeConcat(), ...inputs.flatMap((p) => ['-i', p]), '-o', outputPath, '-v'], { encoding: 'utf8' });
|
|
535
|
+
assert.equal(result.status, 0, result.stderr);
|
|
536
|
+
assert.ok(result.stderr.includes('merge: 3 input(s), 31 entries, 13 fold(s)'), result.stderr);
|
|
537
|
+
assert.equal(openBeast2PagesFor(DT)(new Uint8Array(readFileSync(outputPath))).elementCount, 31);
|
|
538
|
+
const rangedPath = join(tempDir, 'cli-ranged.beast2');
|
|
539
|
+
const ranged = spawnSync(process.execPath, [bin, 'merge', '--merge', writeConcat(), '--range', writeRange('cli', 7n, 22n), ...inputs.flatMap((p) => ['-i', p]), '-o', rangedPath, '-v'], { encoding: 'utf8' });
|
|
540
|
+
assert.equal(ranged.status, 0, ranged.stderr);
|
|
541
|
+
assert.ok(ranged.stderr.includes('merge: 3 input(s), 15 entries, 11 fold(s)'), ranged.stderr);
|
|
542
|
+
const both = spawnSync(process.execPath, [bin, 'merge', '--merge', writeConcat(), '--union', '-i', inputs[0], '-o', join(tempDir, 'both.beast2')], { encoding: 'utf8' });
|
|
543
|
+
assert.equal(both.status, 1);
|
|
544
|
+
assert.ok(both.stderr.includes('Error: --merge and --union are two folds — give one'), both.stderr);
|
|
545
|
+
});
|
|
546
|
+
it('the emit writer segments a value exactly as the paged encoder does', () => {
|
|
547
|
+
// One value segments the same wherever it is written (#770): a task that
|
|
548
|
+
// RETURNS a collection writes it through encodeBeast2PagedFor, one that
|
|
549
|
+
// emits or merges writes it through this writer, and a content-addressed
|
|
550
|
+
// store must see one blob. The writer opened at the element cap where the
|
|
551
|
+
// encoder probes its first entries, so rows above a segment's share got
|
|
552
|
+
// one oversized first segment and every later boundary shifted.
|
|
553
|
+
//
|
|
554
|
+
// Incompressible rows, from a deterministic LCG: deflate shrinks anything
|
|
555
|
+
// patterned back under the byte target, and then every batch is the cap
|
|
556
|
+
// and the two agree no matter what.
|
|
557
|
+
let seed = 12345;
|
|
558
|
+
const noise = (chars) => {
|
|
559
|
+
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
|
|
560
|
+
let out = '';
|
|
561
|
+
for (let i = 0; i < chars; i++) {
|
|
562
|
+
seed = (seed * 48271) % 2147483647;
|
|
563
|
+
out += alphabet[(seed >> 8) & 63];
|
|
564
|
+
}
|
|
565
|
+
return out;
|
|
566
|
+
};
|
|
567
|
+
for (const width of [4_000, 200_000]) {
|
|
568
|
+
seed = 12345;
|
|
569
|
+
const rows = Array.from({ length: 60 }, (_, i) => [BigInt(i), noise(width)]);
|
|
570
|
+
const value = new SortedMap(rows, compareFor(IntegerType));
|
|
571
|
+
const paged = encodeBeast2PagedFor(DT)(value);
|
|
572
|
+
const path = join(tempDir, `segments-${width}.beast2`);
|
|
573
|
+
const writer = new EmitFileWriter('dict', toEastTypeValue(DT), path);
|
|
574
|
+
for (const [key, text] of rows)
|
|
575
|
+
writer.push([key, text]);
|
|
576
|
+
writer.finishClose();
|
|
577
|
+
assert.deepEqual(new Uint8Array(readFileSync(path)), paged, `a ${width}-char row segments the same emitted as returned`);
|
|
578
|
+
}
|
|
579
|
+
});
|
|
580
|
+
it('a container two entries share stays one NEW and one REF', () => {
|
|
581
|
+
// The writer scopes beast2 aliasing per SEGMENT, so a container two
|
|
582
|
+
// entries of one segment share is written once and referenced. A merge
|
|
583
|
+
// of such an input alone must write its bytes back exactly, because the
|
|
584
|
+
// merge writes through the same writer `run --emit` does. east-c encoded
|
|
585
|
+
// each entry under its own scope and wrote the shared value twice — a
|
|
586
|
+
// bigger blob and a different hash for one value, and the two runners
|
|
587
|
+
// disagreeing; the same fixture is `merge_aliased.beast2` in east-c's
|
|
588
|
+
// and east-py's suites.
|
|
589
|
+
const VT = StructType({ tags: ArrayType(StringType) });
|
|
590
|
+
const shared = ['x', 'y', 'zzzzzzzzzzzzzzzzzzzz'];
|
|
591
|
+
const inputPath = join(tempDir, 'aliased.beast2');
|
|
592
|
+
writeFileSync(inputPath, encodeBeast2PagedFor(DictType(IntegerType, VT), { batchSize: 10 })(new SortedMap([[1n, { tags: shared }], [2n, { tags: shared }]], compareFor(IntegerType))));
|
|
593
|
+
const outputPath = join(tempDir, 'aliased-merged.beast2');
|
|
594
|
+
assert.deepEqual(mergeBlobs([inputPath], outputPath), { inputs: 1, entries: 2, folds: 0 });
|
|
595
|
+
assert.deepEqual(new Uint8Array(readFileSync(outputPath)), new Uint8Array(readFileSync(inputPath)));
|
|
596
|
+
});
|
|
597
|
+
it('the merge command refuses its arguments in the other runners\' words', () => {
|
|
598
|
+
// One sentence per condition, identical on east-c, east-node and east-py:
|
|
599
|
+
// the three runners are interchangeable, so a task that names one of them
|
|
600
|
+
// must fail the same way on any of them. One line, no JS stack — a
|
|
601
|
+
// refusal of the command line is the user's error, not the program's.
|
|
602
|
+
const bin = fileURLToPath(new URL('../bin/east-node.mjs', import.meta.url));
|
|
603
|
+
const out = join(tempDir, 'args.beast2');
|
|
604
|
+
const input = writeDictInput('a');
|
|
605
|
+
const cases = [
|
|
606
|
+
[[], 'Error: merge requires at least one -i input'],
|
|
607
|
+
[['-o', out], 'Error: merge requires at least one -i input'],
|
|
608
|
+
[['-i', input], 'Error: merge requires -o FILE'],
|
|
609
|
+
[['-i', input, '-o', out, '--merge', writeConcat(), '--union'],
|
|
610
|
+
'Error: --merge and --union are two folds — give one'],
|
|
611
|
+
[['-i', input, '-o', join(tempDir, 'args.bin')],
|
|
612
|
+
'Error: merge requires a .beast2 output file (-o)'],
|
|
613
|
+
];
|
|
614
|
+
for (const [args, message] of cases) {
|
|
615
|
+
const result = spawnSync(process.execPath, [bin, 'merge', ...args], { encoding: 'utf8' });
|
|
616
|
+
assert.equal(result.status, 1, result.stderr);
|
|
617
|
+
assert.deepEqual(result.stderr.trimEnd().split('\n'), [message]);
|
|
618
|
+
}
|
|
619
|
+
});
|
|
620
|
+
it('a repeated flag takes one value, so it never swallows the ir file', () => {
|
|
621
|
+
// `--stream <index...>` (and `-i`/`-p` variadic) ate the positional that
|
|
622
|
+
// followed, so `run --stream 0 program.beast2` reported its IR file
|
|
623
|
+
// missing — while east-c and east-py, whose flags take one value and
|
|
624
|
+
// repeat, accepted the same line. Every runner README documents the
|
|
625
|
+
// repeated form.
|
|
626
|
+
const bin = fileURLToPath(new URL('../bin/east-node.mjs', import.meta.url));
|
|
627
|
+
const irPath = writeIr('streamed.beast2', pairEmitter([{ key: 1n, value: 'a' }]));
|
|
628
|
+
const stdDir = fileURLToPath(new URL('../../east-node-std', import.meta.url));
|
|
629
|
+
const result = spawnSync(process.execPath, [bin, 'run', '--stream', '0', irPath, '-p', '@elaraai/east-node-std',
|
|
630
|
+
'--emit', 'dict', '-o', join(tempDir, 'streamed-out.beast2')], { env: { ...process.env, E3_RUNNER_SEARCH_DIRS: stdDir }, encoding: 'utf8' });
|
|
631
|
+
// The IR file was parsed as the positional — the run gets as far as the
|
|
632
|
+
// stream index, and refuses it in the words east-c uses.
|
|
633
|
+
assert.ok(!result.stderr.includes('Missing <ir_file>'), result.stderr);
|
|
634
|
+
assert.deepEqual(result.stderr.trimEnd().split('\n'), ['Error: --stream index 0 out of range (0 inputs)']);
|
|
635
|
+
});
|
|
636
|
+
// ---- The stdin lifeline -----------------------------------------------
|
|
637
|
+
it('a runner given the stdin lifeline exits once its stdin closes, mid-computation', async () => {
|
|
638
|
+
// Gate (c): with --exit-with-parent and a stdin pipe nobody writes, the
|
|
639
|
+
// runner exits once that pipe closes. The sink opens the output file
|
|
640
|
+
// before the body runs, so the file's existence is the sign the runner
|
|
641
|
+
// is up and computing; the body loops forever after one emission — only
|
|
642
|
+
// the watcher stops it.
|
|
643
|
+
const spin = East.function([emitKeyType], NullType, ($, emit) => {
|
|
644
|
+
$(emit(1n));
|
|
645
|
+
const turns = $.let(0n);
|
|
646
|
+
$.while(true, ($) => {
|
|
647
|
+
$.assign(turns, turns.add(1n));
|
|
648
|
+
});
|
|
279
649
|
});
|
|
280
|
-
|
|
650
|
+
const bin = fileURLToPath(new URL('../bin/east-node.mjs', import.meta.url));
|
|
651
|
+
// As in the wide-input test: point the platform loader at east-node-std.
|
|
652
|
+
const stdDir = fileURLToPath(new URL('../../east-node-std', import.meta.url));
|
|
653
|
+
const outputPath = join(tempDir, 'spin-output.beast2');
|
|
654
|
+
const child = spawn(process.execPath, [bin, 'run', '--exit-with-parent', writeIr('spin.beast2', spin), '-p', '@elaraai/east-node-std', '--emit', 'set', '-o', outputPath], { env: { ...process.env, E3_RUNNER_SEARCH_DIRS: stdDir }, stdio: ['pipe', 'ignore', 'pipe'] });
|
|
655
|
+
const exited = new Promise((resolve) => {
|
|
656
|
+
child.on('exit', (code, signal) => resolve({ code, signal }));
|
|
657
|
+
});
|
|
658
|
+
let stderr = '';
|
|
659
|
+
child.stderr.setEncoding('utf8');
|
|
660
|
+
child.stderr.on('data', (chunk) => { stderr += chunk; });
|
|
661
|
+
const running = (async () => {
|
|
662
|
+
while (!existsSync(outputPath)) {
|
|
663
|
+
if (child.exitCode !== null || child.signalCode !== null)
|
|
664
|
+
return false;
|
|
665
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
666
|
+
}
|
|
667
|
+
return true;
|
|
668
|
+
})();
|
|
669
|
+
try {
|
|
670
|
+
// Bounded liveness waits: start-up, then the watcher.
|
|
671
|
+
assert.equal(await within(running, 30_000), true, `the runner never opened its output:\n${stderr}`);
|
|
672
|
+
child.stdin.destroy(); // the lifeline closes
|
|
673
|
+
const outcome = await within(exited, 10_000);
|
|
674
|
+
assert.ok(outcome !== undefined, 'the runner outlived its closed stdin by 10 s');
|
|
675
|
+
// The watcher kills the whole process: nothing else sends it SIGKILL.
|
|
676
|
+
// Windows has no signals: there the kill terminates the process with
|
|
677
|
+
// exit code 1, and the runner reports no error of its own.
|
|
678
|
+
if (process.platform === 'win32')
|
|
679
|
+
assert.deepEqual({ ...outcome, stderr }, { code: 1, signal: null, stderr: '' });
|
|
680
|
+
else
|
|
681
|
+
assert.equal(outcome.signal, 'SIGKILL', stderr);
|
|
682
|
+
}
|
|
683
|
+
finally {
|
|
684
|
+
if (child.exitCode === null && child.signalCode === null)
|
|
685
|
+
child.kill('SIGKILL');
|
|
686
|
+
}
|
|
687
|
+
});
|
|
688
|
+
it('a runner given the stdin lifeline exits when its body returns, its watcher long since reading', async () => {
|
|
689
|
+
// Gate (c): the watcher never holds up the runner's own exit. The only
|
|
690
|
+
// platform is an empty package, so nothing in the runner touches stdin and
|
|
691
|
+
// it stays the pipe the parent made. The body computes long enough for the
|
|
692
|
+
// watcher to be reading stdin when it returns, and stdin stays open: the
|
|
693
|
+
// runner must still exit 0 with its output written. (A watcher blocked in
|
|
694
|
+
// a synchronous read kept such a runner alive forever: process exit joins
|
|
695
|
+
// the worker thread.)
|
|
696
|
+
const platformDir = join(tempDir, 'node_modules', 'empty-platform');
|
|
697
|
+
mkdirSync(platformDir, { recursive: true });
|
|
698
|
+
writeFileSync(join(platformDir, 'package.json'), JSON.stringify({
|
|
699
|
+
name: 'empty-platform',
|
|
700
|
+
version: '0.0.0',
|
|
701
|
+
type: 'module',
|
|
702
|
+
exports: { './platform': './platform.js', './package.json': './package.json' },
|
|
703
|
+
}));
|
|
704
|
+
writeFileSync(join(platformDir, 'platform.js'), 'export default [];\n');
|
|
705
|
+
const busy = East.function([], IntegerType, ($) => {
|
|
706
|
+
const turns = $.let(0n);
|
|
707
|
+
$.while(turns.lessThan(30000000n), ($) => {
|
|
708
|
+
$.assign(turns, turns.add(1n));
|
|
709
|
+
});
|
|
710
|
+
return turns;
|
|
711
|
+
});
|
|
712
|
+
const bin = fileURLToPath(new URL('../bin/east-node.mjs', import.meta.url));
|
|
713
|
+
const outputPath = join(tempDir, 'busy-output.beast2');
|
|
714
|
+
const child = spawn(process.execPath, [bin, 'run', writeIr('busy.beast2', busy), '--exit-with-parent', '-p', 'empty-platform', '-o', outputPath], { env: { ...process.env, E3_RUNNER_SEARCH_DIRS: tempDir }, stdio: ['pipe', 'ignore', 'pipe'] });
|
|
715
|
+
const exited = new Promise((resolve) => {
|
|
716
|
+
child.on('exit', (code, signal) => resolve({ code, signal }));
|
|
717
|
+
});
|
|
718
|
+
let stderr = '';
|
|
719
|
+
child.stderr.setEncoding('utf8');
|
|
720
|
+
child.stderr.on('data', (chunk) => { stderr += chunk; });
|
|
721
|
+
try {
|
|
722
|
+
// A bounded liveness wait; the lifeline stays open throughout.
|
|
723
|
+
const outcome = await within(exited, 60_000);
|
|
724
|
+
assert.ok(outcome !== undefined, `the runner did not exit within 60 s of starting:\n${stderr}`);
|
|
725
|
+
assert.deepEqual(outcome, { code: 0, signal: null }, stderr);
|
|
726
|
+
assert.equal(decodeBeast2For(IntegerType)(new Uint8Array(readFileSync(outputPath))), 30000000n);
|
|
727
|
+
}
|
|
728
|
+
finally {
|
|
729
|
+
if (child.exitCode === null && child.signalCode === null)
|
|
730
|
+
child.kill('SIGKILL');
|
|
731
|
+
child.stdin.destroy();
|
|
732
|
+
}
|
|
733
|
+
});
|
|
734
|
+
it('a runner given the stdin lifeline keeps full use of its stdin while the watcher reads it', async () => {
|
|
735
|
+
// The platform opens the process's stdin two seconds in, once the watcher is
|
|
736
|
+
// surely reading — as the first ESM import of `node:process` does, the
|
|
737
|
+
// builtin's facade reading every export — over the plain 'pipe' a parent
|
|
738
|
+
// gives, which on Windows is synchronous: a read pending on it holds the
|
|
739
|
+
// pipe's file-object lock, and that open waited for the parent to go. The
|
|
740
|
+
// runner opens stdin before its watcher reads, so it must load, run and
|
|
741
|
+
// exit 0 with the lifeline still open.
|
|
742
|
+
const platformDir = join(tempDir, 'node_modules', 'late-stdin-platform');
|
|
743
|
+
mkdirSync(platformDir, { recursive: true });
|
|
744
|
+
writeFileSync(join(platformDir, 'package.json'), JSON.stringify({
|
|
745
|
+
name: 'late-stdin-platform',
|
|
746
|
+
version: '0.0.0',
|
|
747
|
+
type: 'module',
|
|
748
|
+
exports: { './platform': './platform.js', './package.json': './package.json' },
|
|
749
|
+
}));
|
|
750
|
+
writeFileSync(join(platformDir, 'platform.js'), [
|
|
751
|
+
'await new Promise((resolve) => setTimeout(resolve, 2000));',
|
|
752
|
+
'process.stdin;',
|
|
753
|
+
'export default [];',
|
|
754
|
+
].join('\n'));
|
|
755
|
+
const one = East.function([], IntegerType, (_$) => 1n);
|
|
756
|
+
const bin = fileURLToPath(new URL('../bin/east-node.mjs', import.meta.url));
|
|
757
|
+
const outputPath = join(tempDir, 'late-stdin-output.beast2');
|
|
758
|
+
const child = spawn(process.execPath, [bin, 'run', writeIr('one.beast2', one), '--exit-with-parent', '-p', 'late-stdin-platform', '-o', outputPath], { env: { ...process.env, E3_RUNNER_SEARCH_DIRS: tempDir }, stdio: ['pipe', 'ignore', 'pipe'] });
|
|
759
|
+
const exited = new Promise((resolve) => {
|
|
760
|
+
child.on('exit', (code, signal) => resolve({ code, signal }));
|
|
761
|
+
});
|
|
762
|
+
let stderr = '';
|
|
763
|
+
child.stderr.setEncoding('utf8');
|
|
764
|
+
child.stderr.on('data', (chunk) => { stderr += chunk; });
|
|
765
|
+
try {
|
|
766
|
+
// A bounded liveness wait; the lifeline stays open throughout.
|
|
767
|
+
const outcome = await within(exited, 30_000);
|
|
768
|
+
assert.ok(outcome !== undefined, `the runner never finished loading its platform — opening stdin waited on the watcher's read:\n${stderr}`);
|
|
769
|
+
assert.deepEqual(outcome, { code: 0, signal: null }, stderr);
|
|
770
|
+
assert.equal(decodeBeast2For(IntegerType)(new Uint8Array(readFileSync(outputPath))), 1n);
|
|
771
|
+
}
|
|
772
|
+
finally {
|
|
773
|
+
if (child.exitCode === null && child.signalCode === null)
|
|
774
|
+
child.kill('SIGKILL');
|
|
775
|
+
child.stdin.destroy();
|
|
776
|
+
}
|
|
281
777
|
});
|
|
282
778
|
});
|
|
283
779
|
describe('frozen inputs', () => {
|