kosa8 docs

Guides / JavaScript & TypeScript / Fork and explore

Fork and explore

A fork copies a running sandbox into live, independent copies, each resuming from the same instant with its processes intact. explore builds best-of-N on top: one fork per approach, all run at the same time, and the fastest one that succeeds is kept.

Fork

import { Kosa8Client } from '@kosa8/sdk';

const kosa8 = new Kosa8Client();
await kosa8.createSandbox({ name: 'base', egress: 'deny' });
await kosa8.pullIntoSandbox('base', 'alpine:latest');
// The slow part, done once, in a long-lived container (a file stands in for installed dependencies).
const cid = await kosa8.startContainer('base', 'alpine:latest',
  ['sh', '-c', 'echo "deps installed" > /state; while :; do sleep 3600; done']);

const f = await kosa8.fork('base', { n: 3, prefix: 'try' });
console.log(`forked ${f.count} copies in ${f.fork_ms}ms from snapshot ${f.snapshot}`);
for (const s of f.forks) console.log(' ', s.id, s.state, 'parent', s.parent);

// The container came along, running, under the same id. Each copy then goes its own way.
await kosa8.exec('try1', cid, ['sh', '-c', 'echo "changed in try1" > /state']);
for (const id of ['base', 'try1', 'try2']) {
  console.log(id, '->', (await kosa8.exec(id, cid, ['cat', '/state'])).stdout.trim());
}
for (const s of f.forks) await kosa8.deleteSandbox(s.id);
$ node fork.mjs
forked 3 copies in 2165ms from snapshot base-fork-1792111473462173000
  try1 running parent base
  try2 running parent base
  try3 running parent base
base -> deps installed
try1 -> changed in try1
try2 -> deps installed

The copies are named <prefix>1, <prefix>2, … (the prefix defaults to <sandbox>-fork), and each records its parent. The long-lived container came along in every copy under the same id, already running, so the slow setup was paid for once. A change in one copy touches neither the others nor the parent.

OptionMeaning
nHow many copies. Default 1.
prefixName prefix for the copies.
labelRaise the copies' classification label above the parent's.

The result is { forks, snapshot, fork_ms, count }. snapshot is the temporary snapshot the copies were restored from; after a successful fork it was gone from listSnapshots().

Explore: best of N

Continuing with base from above:

import { Kosa8Client } from '@kosa8/sdk';

const kosa8 = new Kosa8Client();

// Three ways to do the same job. Each runs as `sh -c VARIANT` in a fresh container from
// `image`, inside its own fork of `base`, all at the same time.
const res = await kosa8.explore('base', {
  image: 'alpine:latest',
  variants: [
    'sleep 2; echo "slow but fine"',
    'echo "quick and fine"',
    'echo "this approach is broken" >&2; exit 1',
  ],
  timeoutMs: 30_000,
  prefix: 'attempt',
});
for (const b of res.branches) {
  console.log(b.name === res.winner ? '*' : ' ', b.name, `exit ${b.exit}`, `${b.duration_ms}ms`, JSON.stringify(b.output), b.error ?? '');
}
console.log('winner:', res.winner, '| removed:', res.removed);
console.log('sandboxes now:', (await kosa8.listSandboxes()).map((s) => s.id));
await kosa8.deleteSandbox(res.winner);
$ node explore.mjs
  attempt1 exit 0 2454ms "slow but fine"
* attempt2 exit 0 438ms "quick and fine"
  attempt3 exit 1 455ms "this approach is broken"
winner: attempt2 | removed: 2
sandboxes now: [ 'base', 'attempt2' ]

explore does what kosa8 explore does: it pulls image into the parent (so every branch has it), forks once per variant, runs sh -c VARIANT in a fresh container from image in each branch at the same time, and keeps the fastest branch that exited 0. The others are deleted. The winner is a full sandbox you can keep working in, or delete.

OptionDefaultMeaning
variants(required)Shell commands, one per branch. At least two.
imagealpine:latestThe image each variant runs in.
pulltruePull image into the parent first. Set false if it is already there.
keepAllfalseKeep every branch, not only the winner.
keepKeep this branch (by id) instead of the winner. It must be one of the branches, so you need to know the prefix.
timeoutMs120000Per-branch limit. A branch that runs over is recorded with an error.
prefix<sandbox>-explore-<n>Branch names are <prefix>1, <prefix>2, ….

Each branch in branches is { name, variant, exit, output, duration_ms, error? }, where output is stdout and stderr together. winner is "" when every branch failed.

Each variant runs in a new container

The variant runs in a fresh container from image, inside the fork. It inherits the fork's mounts, images and network rule, but not files that other containers wrote: those are in their own containers' filesystems. Put shared setup in the image, or on a read-only mount, as the parallel test variants recipe does. A timed-out branch's process is not killed by the time limit (the request is abandoned); it goes when the branch is deleted.

Sizing

Each copy is a whole machine with the parent's memory size. Disks are copy-on-write clones and cost little until written, but memory is real: four copies of a 1 GiB sandbox need 4 GiB more, and on a Mac that pushes into swap, which lives on the same disk. In our tests a four-way explore of 1 GiB sandboxes on a nearly full disk failed partway; the same recipe with three 512 MiB sandboxes ran in 1.4 s per branch. Make the parent as small as the work allows before you fork it.

A fork that fails partway leaves debris

If copy 3 of 3 fails, copies 1 and 2 and the fork's snapshot (<sandbox>-fork-…) are left behind, and explore rejects without cleaning up. List them with listSandboxes() and listSnapshots() and delete them. In v0.9.10 the failed copy's name could also stay reserved until the daemon restarted ("sandbox tz3 already exists"), so use a new prefix for the retry.

Fixed in kosa8 0.9.36: a fork keeps all of its copies or none. A failed copy removes the ones already made and the fork's snapshot, releases every name, and the error ends "(nothing was kept: …)". A name that is taken fails the fork with a Kosa8ConflictError before anything is snapshotted.

Fork, explore and rewind restore saved machines, so on a Mac they need the screen unlocked; see Troubleshooting.

A complete recipe

Parallel test variants: one test suite run under three timezones at once, from a read-only mount.