|
4 | 4 | * README section 3b: what the terminal consumes. A session keeps what must survive a worker's death (cwd, |
5 | 5 | * env), runs one script at a time, stops with Ctrl+C, and completes for Tab from the workspace itself. |
6 | 6 | * |
7 | | - * What it cannot keep it says: when a terminal's engine is replaced and its /tmp held files, the next line |
8 | | - * carries one note that /tmp was emptied; variables over the bounds are named, not silently lost. |
| 7 | + * What it cannot keep it says: when a terminal's engine is replaced and its /tmp held anything (files, or only |
| 8 | + * folders: stats.tmpEntries), the next line carries one note that /tmp was emptied; variables over the bounds |
| 9 | + * are named, not silently lost. |
9 | 10 | *--------------------------------------------------------------------------------------------*/ |
10 | 11 | // @ts-check |
11 | 12 | 'use strict'; |
@@ -405,6 +406,24 @@ async function test(/** @type {string} */ name, /** @type {() => Promise<void>} |
405 | 406 | r.rt.dispose(); |
406 | 407 | }); |
407 | 408 |
|
| 409 | + await test('the /tmp note comes before what the commit says too: a replaced worker, then a line whose changes are refused', async () => { |
| 410 | + const r = createRig(); |
| 411 | + const te = new TextEncoder(); |
| 412 | + /** @type {any[]} */ let changes = []; |
| 413 | + r.engines.state.onRun = async (run, w, h) => { h.done({ exitCode: 0, how: 'exit', cwd: run.cwd, changes, stats: { tmpFiles: 2 } }); }; |
| 414 | + const s = r.rt.createSession(); |
| 415 | + await s.run('echo x > /tmp/a'); |
| 416 | + await r.clock.advance(120000); |
| 417 | + r.ws.put('/scratch/a.txt', 'changed meanwhile'); |
| 418 | + changes = [{ kind: 'write', path: '/scratch/a.txt', existed: true, before: te.encode('old'), after: te.encode('new') }]; |
| 419 | + const res = await s.run('sed -i s/old/new/ a.txt'); |
| 420 | + assert.strictEqual(res.notes.length, 3, res.notes.join('\n')); |
| 421 | + assert.ok(EMPTIED.test(res.notes[0]), 'first: ' + res.notes[0]); |
| 422 | + assert.ok(/refused: \/scratch\/a\.txt changed while the command ran/.test(res.notes[1]), res.notes[1]); |
| 423 | + assert.ok(/NONE of this command's file changes were applied/.test(res.notes[2]), res.notes[2]); |
| 424 | + r.rt.dispose(); |
| 425 | + }); |
| 426 | + |
408 | 427 | await test('at the worker cap the one replaced is the least recently used idle worker WITHOUT /tmp files: the files are the one thing that cannot be made again', async () => { |
409 | 428 | const r = createRig({ limits: { workers: { max: 2 } } }); |
410 | 429 | /** @type {Record<string, number>} */ const files = { keeper: 3, plain: 0 }; |
@@ -555,6 +574,128 @@ async function test(/** @type {string} */ name, /** @type {() => Promise<void>} |
555 | 574 | nonsense.rt.dispose(); |
556 | 575 | }); |
557 | 576 |
|
| 577 | + /* ===== fix round 3: what /tmp holds is files AND folders ================================================ */ |
| 578 | + |
| 579 | + /** |
| 580 | + * The engine's side as the current one reports it for a terminal run: `stats` exactly as given (tmpFiles, and |
| 581 | + * tmpEntries: the files and the folders). It is read at every run, so a test can change what /tmp "holds". |
| 582 | + * @param {any} r @param {() => any} stats |
| 583 | + */ |
| 584 | + function reports(r, stats) { |
| 585 | + r.engines.state.onRun = async (/** @type {any} */ run, /** @type {any} */ w, /** @type {any} */ h) => { |
| 586 | + if (run.script === 'hang') { await new Promise(() => { }); } |
| 587 | + h.done({ exitCode: 0, how: 'exit', cwd: run.cwd, stats: stats() }); |
| 588 | + }; |
| 589 | + } |
| 590 | + /** Every note about /tmp in `notes`. @param {string[]} notes */ |
| 591 | + const tmpNotes = (notes) => notes.filter((t) => /\/tmp was emptied/.test(t)).map((t) => (/\(it held ([^)]*)\)/.exec(t) || [])[1]); |
| 592 | + |
| 593 | + await test('a /tmp that holds only an empty folder is something: the worker is kept twice as long, and the loss is said, as "1 file or folder"', async () => { |
| 594 | + const r = createRig(); |
| 595 | + reports(r, () => ({ tmpFiles: 0, tmpEntries: 1 })); |
| 596 | + const s = r.rt.createSession(); |
| 597 | + await s.run('mkdir /tmp/emptydir'); |
| 598 | + await r.clock.advance(60000); |
| 599 | + assert.strictEqual(r.engines.workers[0].terminated, false, 'a minute is not enough for a /tmp that holds a folder'); |
| 600 | + await r.clock.advance(59999); |
| 601 | + assert.strictEqual(r.engines.workers[0].terminated, false); |
| 602 | + await r.clock.advance(1); |
| 603 | + assert.strictEqual(r.engines.workers[0].terminated, true, 'two minutes are'); |
| 604 | + const after = await s.run('ls /tmp'); |
| 605 | + assert.deepStrictEqual(tmpNotes(after.notes), ['1 file or folder']); |
| 606 | + assert.deepStrictEqual(tmpNotes((await s.run('ls /tmp')).notes), [], 'said once'); |
| 607 | + r.rt.dispose(); |
| 608 | + }); |
| 609 | + |
| 610 | + await test('the note says "N files and folders" with the engine\'s own number: five entries of which two are files', async () => { |
| 611 | + const r = createRig(); |
| 612 | + reports(r, () => ({ tmpFiles: 2, tmpEntries: 5 })); |
| 613 | + const s = r.rt.createSession(); |
| 614 | + await s.run('x'); |
| 615 | + await r.clock.advance(120000); |
| 616 | + const after = await s.run('ls /tmp'); |
| 617 | + assert.strictEqual(after.notes.length, 1, after.notes.join('\n')); |
| 618 | + assert.ok(/^\[LevelCode Shell: \/tmp was emptied when the shell restarted \(it held 5 files and folders\): /.test(after.notes[0]), after.notes[0]); |
| 619 | + assert.ok(/Keep what matters in a workspace folder\]$/.test(after.notes[0]), after.notes[0]); |
| 620 | + r.rt.dispose(); |
| 621 | + }); |
| 622 | + |
| 623 | + await test('an engine that reports only stats.tmpFiles is believed as before: files, and the old wording', async () => { |
| 624 | + for (const [files, said] of /** @type {Array<[number, string]>} */ ([[3, '3 files'], [1, '1 file']])) { |
| 625 | + const r = createRig(); |
| 626 | + reports(r, () => ({ tmpFiles: files })); |
| 627 | + const s = r.rt.createSession(); |
| 628 | + await s.run('x'); |
| 629 | + await r.clock.advance(120000); |
| 630 | + assert.deepStrictEqual(tmpNotes((await s.run('x')).notes), [said]); |
| 631 | + r.rt.dispose(); |
| 632 | + } |
| 633 | + }); |
| 634 | + |
| 635 | + await test('a tmpEntries that is not a count is as good as none: the files count is used, with the wording of files', async () => { |
| 636 | + for (const bad of [-1, NaN, Infinity, '3', null, {}, true]) { |
| 637 | + const r = createRig(); |
| 638 | + reports(r, () => ({ tmpFiles: 3, tmpEntries: bad })); |
| 639 | + const s = r.rt.createSession(); |
| 640 | + await s.run('x'); |
| 641 | + await r.clock.advance(120000); |
| 642 | + assert.deepStrictEqual(tmpNotes((await s.run('x')).notes), ['3 files'], JSON.stringify(bad)); |
| 643 | + r.rt.dispose(); |
| 644 | + } |
| 645 | + }); |
| 646 | + |
| 647 | + await test('both counts present and in disagreement (fewer entries than files): the larger is believed - a loss of files is not to go unsaid', async () => { |
| 648 | + const r = createRig(); |
| 649 | + reports(r, () => ({ tmpFiles: 4, tmpEntries: 1 })); |
| 650 | + const s = r.rt.createSession(); |
| 651 | + await s.run('x'); |
| 652 | + await r.clock.advance(120000); |
| 653 | + assert.deepStrictEqual(tmpNotes((await s.run('x')).notes), ['4 files and folders']); |
| 654 | + r.rt.dispose(); |
| 655 | + }); |
| 656 | + |
| 657 | + await test('an empty /tmp, or counts that are not counts, say nothing and keep the worker for the usual minute', async () => { |
| 658 | + for (const stats of [{ tmpFiles: 0, tmpEntries: 0 }, { tmpEntries: 0 }, { tmpEntries: -3, tmpFiles: NaN }, { tmpEntries: '2' }, { tmpEntries: 0.4 }, {}]) { |
| 659 | + const r = createRig(); |
| 660 | + reports(r, () => stats); |
| 661 | + const s = r.rt.createSession(); |
| 662 | + await s.run('x'); |
| 663 | + await r.clock.advance(60000); |
| 664 | + assert.strictEqual(r.engines.workers[0].terminated, true, JSON.stringify(stats) + ': the usual minute'); |
| 665 | + assert.deepStrictEqual((await s.run('x')).notes, [], JSON.stringify(stats)); |
| 666 | + r.rt.dispose(); |
| 667 | + } |
| 668 | + }); |
| 669 | + |
| 670 | + await test('at the worker cap a worker whose /tmp holds only a folder is kept in preference to an empty one, like one that holds files', async () => { |
| 671 | + const r = createRig({ limits: { workers: { max: 2 } } }); |
| 672 | + /** @type {Record<string, any>} */ const by = { keeper: { tmpFiles: 0, tmpEntries: 1 }, plain: { tmpFiles: 0, tmpEntries: 0 } }; |
| 673 | + r.engines.state.onRun = async (run, w, h) => { h.done({ exitCode: 0, how: 'exit', cwd: run.cwd, stats: by[run.script.split(' ')[0]] || {} }); }; |
| 674 | + const keeper = r.rt.createSession(); |
| 675 | + const plain = r.rt.createSession(); |
| 676 | + await keeper.run('keeper'); // the older one, and it holds a folder |
| 677 | + await r.clock.advance(1000); |
| 678 | + await plain.run('plain'); |
| 679 | + await r.clock.advance(1000); |
| 680 | + await r.rt.createSession().run('plain'); |
| 681 | + assert.deepStrictEqual(r.engines.workers.map((w) => w.terminated), [false, true, false], 'the newer, empty one was replaced'); |
| 682 | + assert.deepStrictEqual((await keeper.run('keeper')).notes, [], 'its /tmp is there'); |
| 683 | + r.rt.dispose(); |
| 684 | + }); |
| 685 | + |
| 686 | + await test('an agent\'s worker never remembers its /tmp, folders or not: the usual minute, and no note', async () => { |
| 687 | + const r = createRig(); |
| 688 | + reports(r, () => ({ tmpFiles: 2, tmpEntries: 9 })); |
| 689 | + for (let i = 0; i < 2; i++) { |
| 690 | + const o = observe(r.rt.spawnAgent('x', { cwd: '/scratch' }), 3000); |
| 691 | + await o.closed; |
| 692 | + await r.clock.advance(60000); |
| 693 | + assert.strictEqual(r.engines.workers[r.engines.workers.length - 1].terminated, true); |
| 694 | + assert.ok(!/\/tmp was emptied/.test(o.stderr), o.stderr); |
| 695 | + } |
| 696 | + r.rt.dispose(); |
| 697 | + }); |
| 698 | + |
558 | 699 | /* ===== completion =============================================================================== */ |
559 | 700 |
|
560 | 701 | await test('complete(command): what exists, by prefix, sorted, the engine\'s own list once it has started, builtins too', async () => { |
|
0 commit comments