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.
| Option | Meaning |
|---|---|
n | How many copies. Default 1. |
prefix | Name prefix for the copies. |
label | Raise 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.
| Option | Default | Meaning |
|---|---|---|
variants | (required) | Shell commands, one per branch. At least two. |
image | alpine:latest | The image each variant runs in. |
pull | true | Pull image into the parent first. Set false if it is already there. |
keepAll | false | Keep every branch, not only the winner. |
keep | Keep this branch (by id) instead of the winner. It must be one of the branches, so you need to know the prefix. | |
timeoutMs | 120000 | Per-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.
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.
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.
Parallel test variants: one test suite run under three timezones at once, from a read-only mount.