Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
87 commits
Select commit Hold shift + click to select a range
125723a
feat(sandbox): a local process runtime for Sandboxes, LAYERFS_SANDBOX…
jiangkoumo Oct 22, 2026
e4cbcad
feat(sandbox): a local process runtime for Sandboxes, LAYERFS_SANDBOX…
jiangkoumo Oct 22, 2026
c43060f
feat(sandbox): a local process runtime for Sandboxes, LAYERFS_SANDBOX…
jiangkoumo Oct 22, 2026
0604b72
feat(sandbox): a local process runtime for Sandboxes, LAYERFS_SANDBOX…
jiangkoumo Oct 22, 2026
5099871
feat(sandbox): a local process runtime for Sandboxes, LAYERFS_SANDBOX…
jiangkoumo Oct 22, 2026
12a734a
feat(sandbox): a local process runtime for Sandboxes, LAYERFS_SANDBOX…
jiangkoumo Oct 22, 2026
e20bb93
feat(sandbox): a local process runtime for Sandboxes, LAYERFS_SANDBOX…
jiangkoumo Oct 22, 2026
c5e1502
feat(sandbox): a local process runtime for Sandboxes, LAYERFS_SANDBOX…
jiangkoumo Oct 22, 2026
f832490
feat(sandbox): a local process runtime for Sandboxes, LAYERFS_SANDBOX…
jiangkoumo Oct 22, 2026
726869a
feat(sandbox): a local process runtime for Sandboxes, LAYERFS_SANDBOX…
jiangkoumo Oct 22, 2026
13d8631
feat(sandbox): a local process runtime for Sandboxes, LAYERFS_SANDBOX…
jiangkoumo Oct 22, 2026
eda1537
feat(sandbox): a local process runtime for Sandboxes, LAYERFS_SANDBOX…
jiangkoumo Oct 22, 2026
4f0ce6d
feat(sandbox): a local process runtime for Sandboxes, LAYERFS_SANDBOX…
jiangkoumo Oct 22, 2026
8d2473c
feat(sandbox): a local process runtime for Sandboxes, LAYERFS_SANDBOX…
jiangkoumo Oct 22, 2026
055655d
feat(sandbox): a local process runtime for Sandboxes, LAYERFS_SANDBOX…
jiangkoumo Oct 22, 2026
4906f63
feat(sandbox): a local process runtime for Sandboxes, LAYERFS_SANDBOX…
jiangkoumo Oct 22, 2026
de16c45
feat(sandbox): a local process runtime for Sandboxes, LAYERFS_SANDBOX…
jiangkoumo Oct 22, 2026
3466de3
feat(sandbox): a local process runtime for Sandboxes, LAYERFS_SANDBOX…
jiangkoumo Oct 22, 2026
41a6c20
feat(sandbox): a local process runtime for Sandboxes, LAYERFS_SANDBOX…
jiangkoumo Oct 22, 2026
6d9dd41
feat(sandbox): a local process runtime for Sandboxes, LAYERFS_SANDBOX…
jiangkoumo Oct 22, 2026
4a5caa7
feat(sandbox): a local process runtime for Sandboxes, LAYERFS_SANDBOX…
jiangkoumo Oct 22, 2026
819bd2c
feat(sandbox): a local process runtime for Sandboxes, LAYERFS_SANDBOX…
jiangkoumo Oct 22, 2026
13a791a
fix(sandbox): address the first review of the local runtime (#402)
jiangkoumo Oct 22, 2026
13361ba
fix(sandbox): address the first review of the local runtime (#402)
jiangkoumo Oct 22, 2026
fed0249
fix(sandbox): address the first review of the local runtime (#402)
jiangkoumo Oct 22, 2026
603b561
fix(sandbox): address the first review of the local runtime (#402)
jiangkoumo Oct 22, 2026
1f2307e
fix(sandbox): address the first review of the local runtime (#402)
jiangkoumo Oct 22, 2026
4a66f35
fix(sandbox): address the first review of the local runtime (#402)
jiangkoumo Oct 22, 2026
cba6af0
fix(sandbox): address the first review of the local runtime (#402)
jiangkoumo Oct 22, 2026
f71df4a
fix(sandbox): address the first review of the local runtime (#402)
jiangkoumo Oct 22, 2026
0466af2
fix(sandbox): address the first review of the local runtime (#402)
jiangkoumo Oct 22, 2026
9e0b6a0
fix(sandbox): address the first review of the local runtime (#402)
jiangkoumo Oct 22, 2026
fd35180
fix(sandbox): address the first review of the local runtime (#402)
jiangkoumo Oct 22, 2026
0cf539a
fix(sandbox): address the first review of the local runtime (#402)
jiangkoumo Oct 22, 2026
e88bd55
fix(sandbox): address the first review of the local runtime (#402)
jiangkoumo Oct 22, 2026
0f156e3
fix(sandbox): address the first review of the local runtime (#402)
jiangkoumo Oct 22, 2026
3fa366c
fix(sandbox): address the first review of the local runtime (#402)
jiangkoumo Oct 22, 2026
d53d4fb
fix(sandbox): address the first review of the local runtime (#402)
jiangkoumo Oct 22, 2026
d463067
fix(sandbox): address the first review of the local runtime (#402)
jiangkoumo Oct 22, 2026
8c22d17
fix(sandbox): address the first review of the local runtime (#402)
jiangkoumo Oct 22, 2026
5f83fab
Merge PR #402 head 8c22d1754 (fork feat/sandbox-local-backend) into m…
jiangkoumo Oct 22, 2026
e63af4c
feat(nfs): a loopback NFSv3 export of one mounted Workspace (layerfs-…
jiangkoumo Oct 22, 2026
757e448
feat(daemon): NFS as a kernel mount entry beside FUSE (LAYERFS_KERNEL…
jiangkoumo Oct 22, 2026
6c8ca0e
feat(sandbox): local Sandboxes on macOS and the SDK's native route (L…
jiangkoumo Oct 22, 2026
1deb780
feat(store-service): open the Store on macOS for the native route; bu…
jiangkoumo Oct 22, 2026
5169c30
feat(dsh): the LayerFS Host on the native macOS route (sandbox: nativ…
jiangkoumo Oct 22, 2026
94f15b7
Merge upstream main 1aa56ece7 into feat/macos-native-on-main
jiangkoumo Oct 23, 2026
833acf7
Merge upstream main e8f129c1b into feat/macos-native-on-main
jiangkoumo Oct 23, 2026
ada323c
Merge upstream main 4497b0cbf (v1.1.0, #428) into PR #402 (local port)
jiangkoumo Oct 24, 2026
da213ce
Merge PR #402 local port (ada323c90, with upstream main 4497b0cbf v1.…
jiangkoumo Oct 24, 2026
009931c
layerfs-nfs: macOS mount tolerates slow answers; LOOKUP carries the d…
jiangkoumo Oct 24, 2026
915b622
Merge upstream main c77281e49 (#434 Ruff format, #440) into feat/maco…
jiangkoumo Oct 24, 2026
0cc863a
Merge upstream main ad78914e3 (#449, per-Store overlay name) into PR …
jiangkoumo Oct 25, 2026
4deeefb
Merge upstream main ad78914e3 (#449, per-Store overlay name) into fea…
jiangkoumo Oct 25, 2026
a700af8
layerfs-nfs: keep AppleDouble files in the export's memory instead of…
jiangkoumo Oct 25, 2026
4c1648e
fix(native): bound NFS helper reaping and detach before daemon stop
jiangkoumo Oct 25, 2026
b3f8f80
fix(binding): release Sandbox ownership while unmount waits
jiangkoumo Oct 26, 2026
d3a2a8c
fix(binding): use libc::O_NOFOLLOW for the unmount lease
jiangkoumo Oct 26, 2026
766e67e
fix(sandbox): detach local mounts before stopping the daemon
jiangkoumo Oct 26, 2026
3e427a3
Merge upstream main caebd268d (D8 three-layer split, #465) into PR #4…
jiangkoumo Oct 27, 2026
8b11778
style: rustfmt
jiangkoumo Oct 27, 2026
1931032
Merge upstream main caebd268d (D8 three-layer split, #465) into PR #4…
jiangkoumo Oct 27, 2026
eb21fbb
test(node): cover local Sandbox records through the built addon (#402)
jiangkoumo Oct 27, 2026
7fcfdfb
Merge port/d8-402 8b17038f2 (#402 node tests through the built addon)…
jiangkoumo Oct 27, 2026
8b17038
style: rustfmt
jiangkoumo Oct 27, 2026
1dcd1fb
test(node): native Sandbox records and the unmount lease regression a…
jiangkoumo Oct 27, 2026
a6b53a9
fix(tools): place layerfs-nfs in the engine layer of the boundary map…
jiangkoumo Oct 27, 2026
bf50a5f
docs(node): list native_local in the binding test guide (#402)
jiangkoumo Oct 27, 2026
befe2a9
Merge port/d8-402 bf50a5f70 (#402 binding test guide) into PR #427 (l…
jiangkoumo Oct 27, 2026
9478190
docs(node): native records in the binding test guide (#427)
jiangkoumo Oct 27, 2026
eb0d6d6
Merge upstream main 582bc786f into feat/macos-native-on-main (#427)
jiangkoumo Oct 28, 2026
d55acc1
refactor(node): native is the only local deployment; local is no long…
jiangkoumo Oct 28, 2026
559bff5
feat(sandbox): define provider lifecycle and shared command types
jiangkoumo Oct 28, 2026
8ce4697
feat(sandbox): adapt native macOS runtime to provider traits
jiangkoumo Oct 28, 2026
59c0da2
refactor(api): share provider daemon startup with native macOS
jiangkoumo Oct 28, 2026
c82a903
feat(sandbox): add native Linux provider over the shared local runtime
jiangkoumo Oct 28, 2026
7a6952c
feat(node): restore Linux native deployment through the provider pipe…
jiangkoumo Oct 28, 2026
e5004d6
feat(sandbox): add the Docker provider behind the shared runtime port
jiangkoumo Oct 28, 2026
2e4e3cf
test(node): answer the Docker version probe in the Engine fixture (H25)
jiangkoumo Oct 28, 2026
abed25d
merge: bring in the H25 Engine fixture fix (2e4e3cfec)
jiangkoumo Oct 28, 2026
7922044
feat(sandbox): add Docker options and record variants to provider tra…
jiangkoumo Oct 28, 2026
70ec59e
feat(api): unify SandboxApi behind the runtime provider route (H26)
jiangkoumo Oct 28, 2026
8cffb6b
feat(node): route Docker through DockerProvider and remove deprecated…
jiangkoumo Oct 28, 2026
3147916
Merge upstream main 35d36575c (#500 stalled unmount recovery) into PR…
jiangkoumo Oct 29, 2026
ab6ce70
Merge PR #427 head 3147916ef (upstream main 35d36575c) into B1
jiangkoumo Oct 29, 2026
2518769
test(sandbox): wait for local daemon reaping (H29)
jiangkoumo Oct 29, 2026
5e5486f
Merge PR #427 head 251876979 (H29 local_runtime reaping test fix) int…
jiangkoumo Oct 29, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

28 changes: 27 additions & 1 deletion api/agent/src/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -60,9 +60,19 @@ export function sandboxArg(sandbox) {

/** Deployment settings from the client options, else the environment
* (LAYERFS_<NAME>). A Workspace's Sandbox needs
* every one but the container prefix and daemon host. */
* every one but the container prefix and daemon host. With `sandbox`
* (LAYERFS_SANDBOX) `native` (Linux or macOS), a Sandbox is a host daemon:
* Linux uses FUSE with a root daemon and nonroot commands (501:20);
* macOS uses loopback NFS and this user. It needs the daemon and
* `localDir` (LAYERFS_LOCAL_DIR) and no Docker setting. Unset or `docker` is
* Docker; any other value is refused. */
export function deployment(options = {}) {
const env = (name) => process.env[`LAYERFS_${name}`] || undefined;
const sandbox = options.sandbox ?? env("SANDBOX");
if (sandbox === "native") return nativeDeployment(options, env, sandbox);
if (sandbox !== undefined && sandbox !== "docker") {
throw new LayerFSError("InvalidInput", `deploy.sandbox (LAYERFS_SANDBOX) is docker or native (Linux or macOS), not ${sandbox}`, null);
}
const deploy = {
docker: options.docker ?? env("DOCKER") ?? "/var/run/docker.sock",
image: options.image ?? env("IMAGE"),
Expand All @@ -80,6 +90,22 @@ export function deployment(options = {}) {
return deploy;
}

/** A native Sandbox's settings: the daemon, the directory its Sandboxes
* live in, and the optional name prefix. */
function nativeDeployment(options, env, sandbox) {
const deploy = {
sandbox,
daemon: options.daemon ?? env("DAEMON"),
localDir: options.localDir ?? env("LOCAL_DIR"),
containerPrefix: options.containerPrefix ?? env("CONTAINER_PREFIX"),
};
for (const [name, setting] of [["daemon", "DAEMON"], ["localDir", "LOCAL_DIR"]]) {
if (!deploy[name]) throw new LayerFSError("InvalidInput", `deploy.${name} is not configured (LAYERFS_${setting})`, null);
}
if (!deploy.containerPrefix) delete deploy.containerPrefix;
return deploy;
}

/** Refuses every call on a closed client (or one no factory opened). */
export function live(client) {
if (client?.[STATE]?.closed !== false) {
Expand Down
8 changes: 7 additions & 1 deletion api/agent/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,11 @@ export class LayerFS {
* settings read LAYERFS_HOME, _DOCKER, _IMAGE, _VOLUME and _DAEMON; a
* Sandbox this client starts runs _IMAGE
* on _VOLUME. LAYERFS_CONTAINER_PREFIX (default `layerfs-`) names every
* container it starts `<prefix><project>-<n>`.
* container it starts `<prefix><project>-<n>`. On Linux or macOS,
* LAYERFS_SANDBOX=native starts each Sandbox as a host daemon in a directory
* under LAYERFS_LOCAL_DIR. Linux requires root for FUSE and runs commands
* as 501:20; macOS uses loopback NFS and the caller's identity. No Docker
* setting is read; native provides no isolation from the host.
* LAYERFS_DAEMON_HOST is the host this process
* dials for a daemon's port, published on the Docker host's 127.0.0.1 only;
* set it when the client runs in a container (`host.docker.internal` on
Expand Down Expand Up @@ -129,6 +133,8 @@ export class LayerFS {
volume: env("VOLUME"),
daemon: env("DAEMON"),
...(env("CONTAINER_PREFIX") ? { containerPrefix: env("CONTAINER_PREFIX") } : {}),
...(env("SANDBOX") ? { sandbox: env("SANDBOX") } : {}),
...(env("LOCAL_DIR") ? { localDir: env("LOCAL_DIR") } : {}),
...(env("DAEMON_HOST") ? { daemonHost: env("DAEMON_HOST") } : {}),
},
storePolicy: env("STORE_POLICY"),
Expand Down
109 changes: 109 additions & 0 deletions api/agent/test/native_deployment.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
// Which runtime a Sandbox uses. No real addon, Store or Docker (the
// stand-in binding answers the client's calls):
// node api/agent/test/native_deployment.mjs
// LAYERFS_SANDBOX unset or `docker` gives exactly the Docker deployment it
// always gave; `native` (macOS) needs only the daemon and LAYERFS_LOCAL_DIR
// (no Docker setting); any other value, the former `local` included, is
// refused rather than taken as either.
import assert from "node:assert/strict";
import { createRequire } from "node:module";
import { fileURLToPath } from "node:url";
import { deployment } from "../src/client.js";

const FAKE = fileURLToPath(new URL("./fixture/fake-native.cjs", import.meta.url));
const native = createRequire(import.meta.url)(FAKE);

const clear = () => {
for (const name of Object.keys(process.env)) {
if (name.startsWith("LAYERFS_") || name.startsWith("DSH_LAYERFS_")) delete process.env[name];
}
process.env.LAYERFS_NATIVE = FAKE;
};
const docker = { docker: "/run/docker.sock", image: `sha256:${"a".repeat(64)}`, volume: "lfs-v", daemon: "/opt/layerfs-daemon" };
const setDocker = () => {
process.env.LAYERFS_DOCKER = docker.docker;
process.env.LAYERFS_IMAGE = docker.image;
process.env.LAYERFS_VOLUME = docker.volume;
process.env.LAYERFS_DAEMON = docker.daemon;
};
const store = () => {
process.env.LAYERFS_HOME = "/private/local-home";
process.env.LAYERFS_STORE_PATH = "/fake/store.sqlite";
};

// unset: the Docker deployment, field for field
clear();
setDocker();
assert.deepEqual(deployment(), docker);
// `docker` is the same deployment
process.env.LAYERFS_SANDBOX = "docker";
assert.deepEqual(deployment(), docker);
// the Docker settings are still required there
delete process.env.LAYERFS_IMAGE;
assert.throws(() => deployment(), (e) => e.code === "InvalidInput" && /LAYERFS_IMAGE/.test(e.message));

// native: daemon + localDir, no Docker setting read or required
clear();
process.env.LAYERFS_SANDBOX = "native";
process.env.LAYERFS_DAEMON = "/opt/layerfs-daemon";
process.env.LAYERFS_LOCAL_DIR = "/Users/me/lfs";
assert.deepEqual(deployment(), { sandbox: "native", daemon: "/opt/layerfs-daemon", localDir: "/Users/me/lfs" });
// an image (sandbox.create's option) does not change a native deployment
assert.deepEqual(deployment({ image: docker.image }), { sandbox: "native", daemon: "/opt/layerfs-daemon", localDir: "/Users/me/lfs" });
// the container prefix names native Sandbox directories too
process.env.LAYERFS_CONTAINER_PREFIX = "ci-";
assert.equal(deployment().containerPrefix, "ci-");
delete process.env.LAYERFS_CONTAINER_PREFIX;
// each required setting is named when missing
delete process.env.LAYERFS_LOCAL_DIR;
assert.throws(() => deployment(), (e) => e.code === "InvalidInput" && /LAYERFS_LOCAL_DIR/.test(e.message));
process.env.LAYERFS_LOCAL_DIR = "/Users/me/lfs";
delete process.env.LAYERFS_DAEMON;
assert.throws(() => deployment(), (e) => e.code === "InvalidInput" && /LAYERFS_DAEMON/.test(e.message));
process.env.LAYERFS_DAEMON = "/opt/layerfs-daemon";
// a DSH_LAYERFS_NFS_ name is not mapped (an integration maps its own names)
process.env.DSH_LAYERFS_NFS_MOUNT = "/bin/true";
deployment();
assert.equal(process.env.LAYERFS_NFS_MOUNT, undefined);
// since #428 the SDK reads LAYERFS_ names only: a DSH_LAYERFS_ twin selects nothing
clear();
setDocker();
process.env.DSH_LAYERFS_SANDBOX = "native";
process.env.DSH_LAYERFS_LOCAL_DIR = "/l";
assert.deepEqual(deployment(), docker);

// anything else, the former `local` included, is refused, never silently Docker
for (const typo of ["local", "Native", "nativ", "NATIVE", "podman"]) {
clear();
setDocker();
process.env.LAYERFS_SANDBOX = typo;
assert.throws(() => deployment(), (e) => e.code === "InvalidInput" && e.message.includes(`not ${typo}`));
}

// a client reads LAYERFS_SANDBOX and LAYERFS_LOCAL_DIR once, and
// sandbox.create sends the native deployment (no Docker setting)
clear();
store();
process.env.LAYERFS_SANDBOX = "native";
process.env.LAYERFS_DAEMON = "/opt/layerfs-daemon";
process.env.LAYERFS_LOCAL_DIR = "/Users/me/lfs";
const { LayerFS } = await import("../src/index.js");
const client = await LayerFS.open("website");
assert.equal(client.options.deploy.sandbox, "native");
assert.equal(client.options.deploy.localDir, "/Users/me/lfs");
const before = native.deploys.length;
await client.sandbox.create();
assert.deepEqual(native.deploys.slice(before), [{ sandbox: "native", daemon: "/opt/layerfs-daemon", localDir: "/Users/me/lfs" }]);
// an image given to sandbox.create does not turn it into a Docker deployment
await client.sandbox.create({ image: docker.image });
assert.deepEqual(native.deploys.at(-1), { sandbox: "native", daemon: "/opt/layerfs-daemon", localDir: "/Users/me/lfs" });
await client.close();
// unset: the client carries no new key
clear();
store();
setDocker();
const plain = await LayerFS.open("website");
assert.equal("sandbox" in plain.options.deploy, false);
assert.equal("localDir" in plain.options.deploy, false);
await plain.close();
console.log("native_deployment: ok");
138 changes: 138 additions & 0 deletions api/agent/test/verify/native_unmount_real.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
// Real native SDK regression. Explicit binaries + isolated root are required.
// node api/agent/test/verify/native_unmount_real.mjs /abs/root /abs/addon.node /abs/daemon
import assert from 'node:assert/strict';
import { spawn } from 'node:child_process';
import { mkdir, writeFile, readFile, access, readdir } from 'node:fs/promises';
import { fileURLToPath } from 'node:url';
const [base, addon, daemon, childMode, serialized] = process.argv.slice(2);
assert.ok(base?.startsWith('/') && addon?.startsWith('/') && daemon?.startsWith('/'), 'explicit absolute fixture paths');
const repo = fileURLToPath(new URL('../../../..', import.meta.url));
Object.assign(process.env, {
LAYERFS_SANDBOX: 'native', LAYERFS_NATIVE: addon, LAYERFS_DAEMON: daemon,
LAYERFS_HOME: base+'/home', LAYERFS_LOCAL_DIR: base+'/local', LAYERFS_STORE_PATH: base+'/store/store.sqlite',
LAYERFS_NFS_UNMOUNT: `sh ${base}/helper.sh {dir}`,
});
const { LayerFS } = await import(repo+'/api/agent/src/index.js');
const { LayerFSBackend } = await import(repo+'/api/backend/src/index.js');
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
async function deadline(promise, ms) {
let timer;
try { return await Promise.race([promise, new Promise((_,reject)=>{timer=setTimeout(()=>reject(Error('deadline '+ms)),ms);})]); }
finally { clearTimeout(timer); }
}
async function waitFile(path) {
const end=Date.now()+5000;
while(Date.now()<end){try{await access(path);return;}catch{} await sleep(20);}
throw Error('helper did not start');
}
async function fast(label, call, expected) {
const at=Date.now();
const value=await deadline(call(),900);
assert.ok(Date.now()-at<1000,label);
if(expected)expected(value);
console.log(JSON.stringify({label,elapsedMs:Date.now()-at}));
return value;
}
async function refused(call,state='Unmounting') {
try{await call();assert.fail('expected refusal');}
catch(error){assert.equal(error.code,state==='Unmounting'?'Busy':'Unknown');assert.equal(error.detail?.state,state);assert.ok(error.detail.elapsedMs>=0);return error.detail;}
}
function child(mode, ws) {
const p=spawn(process.execPath,[fileURLToPath(import.meta.url),base,addon,daemon,mode,JSON.stringify(ws)],{env:process.env,stdio:['ignore','pipe','pipe']});
let output='';p.stdout.on('data',d=>output+=d);p.stderr.on('data',d=>output+=d);
const ended=new Promise(resolve=>p.once('exit',(code,signal)=>resolve({code,signal,output})));
return {p,ended};
}
if(childMode){
const lf=await LayerFS.open('unmount');const ws=JSON.parse(serialized);
if(childMode==='probe')await fast('cross-process-same-workspace',()=>refused(()=>lf.workspace.unmount(ws,true)));
if(childMode==='owner')await lf.workspace.unmount(ws,true);
await lf.close();
}else{
assert.equal(process.platform,'darwin','this fixture uses host mount_nfs');
await mkdir(base+'/local',{recursive:true});await mkdir(base+'/seed',{recursive:true});await mkdir(base+'/store',{recursive:true,mode:0o700});
await writeFile(base+'/seed/file','base\n');
await writeFile(base+'/helper.sh',`#!/bin/sh\nprintf '%s' "$1" > '${base}/started'\ni=0\nwhile [ ! -f '${base}/release' ] && [ "$i" -lt 200 ]; do sleep 0.1; i=$((i+1)); done\nif [ -f '${base}/fail' ]; then echo injected-helper-failure >&2; exit 1; fi\nexec /sbin/umount "$1"\n`);
const lf=await LayerFS.init('unmount',base+'/seed');let owner;let active;
try{
await lf.branch.fork('a');await lf.branch.fork('b');
const sb=await lf.sandbox.create();const a=await lf.workspace.mount(sb.sandbox,'a');const b=await lf.workspace.mount(sb.sandbox,'b');
active=lf.workspace.unmount(a,true);active.catch(()=>{});
await waitFile(base+'/started');
await fast('sandbox-log-needs-discovery-lock',()=>LayerFSBackend.of(lf).daemon.sandbox.get_log(sb.sandbox,{lines:2}));
await fast('other-workspace-status',()=>LayerFSBackend.of(lf).daemon.workspace.status(b));
await fast('other-workspace-exec',()=>lf.workspace.exec(b,'printf ready'),r=>assert.equal(r.stdout,'ready'));
await fast('same-workspace-status',()=>refused(()=>LayerFSBackend.of(lf).daemon.workspace.status(a)));
await fast('same-workspace-exec',()=>refused(()=>lf.workspace.exec(a,'true')));
await fast('same-workspace-commit',()=>refused(()=>lf.workspace.commit(a,'must not run')));
await fast('same-workspace-list-changes',()=>refused(()=>LayerFSBackend.of(lf).daemon.workspace.list_changes(a)));
await fast('repeat-unmount',()=>refused(()=>lf.workspace.unmount(a,true)));
await fast('sandbox-close',()=>refused(()=>lf.sandbox.close(sb.sandbox)));
const probe=child('probe',a);assert.equal((await deadline(probe.ended,5000)).code,0);
const another=await fast('new-workspace-mount',()=>lf.workspace.mount(sb.sandbox,'b'));
const locker=spawn('python3',['-c','import fcntl,sys; f=open(sys.argv[1],"r+"); fcntl.flock(f,fcntl.LOCK_EX); print("LOCKED",flush=True); sys.stdin.read(1)',base+'/home/unmount/sandboxes/'+sb.sandbox+'/ownership.lock'],{stdio:['pipe','pipe','pipe']});
try {
await deadline(new Promise((resolve,reject)=>{locker.stdout.once('data',resolve);locker.once('error',reject);}),2000);
await writeFile(base+'/release','go');await sleep(200);
// The terminal result is visible before cleanup can reacquire this lock.
await fast('published-success-before-cleanup-lock',()=>lf.workspace.unmount(a,true));
} finally { locker.stdin.end('x');await deadline(new Promise(r=>locker.once('exit',r)),2000); }
await deadline(active,5000);active=null;
await fast('repeat-completed-unmount',()=>lf.workspace.unmount(a,true));
// Busy and Dirty are reversible: release only this attempt's intent.
await lf.workspace.exec(b,'printf dirty > dirty');
await assert.rejects(lf.workspace.unmount(b),e=>e.code==='Dirty');
await lf.workspace.exec(b,'printf still-usable');
const holder=spawn('/bin/sleep',['5'],{cwd:b.path,stdio:'ignore'});
try { await assert.rejects(lf.workspace.unmount(b,true),e=>e.code==='Busy'); }
finally { holder.kill('SIGTERM');await deadline(new Promise(r=>holder.once('exit',r)),2000); }
await lf.workspace.unmount(b,true);await lf.workspace.unmount(another,true);await lf.sandbox.close(sb.sandbox);
// A killed binding loses its lease, not its intent. No subsequent caller
// may send a second unmount when the daemon may still own the first.
const {unlink}=await import('node:fs/promises');await unlink(base+'/release');await unlink(base+'/started');
const sb2=await lf.sandbox.create();const lost=await lf.workspace.mount(sb2.sandbox,'a');
owner=child('owner',lost);await waitFile(base+'/started');owner.p.kill('SIGKILL');await deadline(owner.ended,5000);
await fast('lost-owner-no-replay',()=>refused(()=>lf.workspace.unmount(lost,true),'Retained'));
await writeFile(base+'/release','go');
await sleep(300);
// Explicit close takes over the orphan while its daemon is healthy;
// it never resends the unknown Workspace unmount.
await fast('lost-owner-close-takeover',()=>lf.sandbox.close(sb2.sandbox));
// A failed helper leaves daemon custody and the original SDK error.
// Repeated calls read that terminal result rather than launching again.
const sb3=await lf.sandbox.create();const kept=await lf.workspace.mount(sb3.sandbox,'a');
await writeFile(base+'/fail','fail');
const error=await lf.workspace.unmount(kept,true).then(()=>null,e=>e);assert.ok(error);
await fast('repeat-retained-result',async()=>{
const repeated=await lf.workspace.unmount(kept,true).then(()=>null,e=>e);
assert.equal(repeated.code,error.code);assert.equal(repeated.message,error.message);assert.equal(repeated.detail.state,'Retained');
});
await fast('retained-close-takeover',()=>lf.sandbox.close(sb3.sandbox));
console.log('UNMOUNT_CONCURRENCY PASS');
}finally{
await writeFile(base+'/release','cleanup');
if(owner?.p.exitCode===null&&owner?.p.signalCode===null)owner.p.kill('SIGKILL');
if(active)await deadline(active.catch(()=>{}),5000);
// Always give the fixture helpers permission to finish. A failed test
// must not leave a stopped helper or a held client request behind.
await lf.close();
// Failure cleanup is scoped by the explicit fixture root. Detach while
// its daemon is still alive; never remove directories through mounts.
const {spawnSync}=await import('node:child_process');
const table=spawnSync('/sbin/mount',[],{encoding:'utf8',timeout:5000});
for(const line of (table.stdout||'').split('\n')){
const point=line.match(/ on (.*) \(/)?.[1];
if(point?.startsWith(base+'/local/')){
const off=spawnSync('/sbin/umount',['-f',point],{encoding:'utf8',timeout:15000,killSignal:'SIGKILL'});
assert.equal(off.status,0,'fixture detach: '+off.stderr);
}
}
const processes=spawnSync('/bin/ps',['-axo','pid=,command='],{encoding:'utf8',timeout:5000});
for(const line of (processes.stdout||'').split('\n')){
if(line.includes(daemon+' --config '+base+'/local/')){
const pid=Number(line.trim().split(/\s+/)[0]);
try{process.kill(pid,'SIGTERM');}catch(e){if(e.code!=='ESRCH')throw e;}
}
}
}
}
Loading