From a9882c9dee151b75cf882db25eabf097c9ea60b6 Mon Sep 17 00:00:00 2001 From: Dotta Date: Sat, 10 Oct 2026 10:28:14 -0500 Subject: [PATCH] Keep owned stdin valid until command completion on Boat Boat invalidates descriptors for unlinked backing files, causing Node startup to abort. Retain the0600 stdin spool until normal cleanup, and reap abandoned command spools only after exact-owner retirement stops its cgroup. Co-Authored-By: Paperclip --- server/src/modules/computers/adapters/boat.ts | 8 +++++++- server/src/modules/computers/application/service.test.ts | 8 ++++++++ server/src/modules/computers/application/service.ts | 5 +++-- 3 files changed, 18 insertions(+), 3 deletions(-) diff --git a/server/src/modules/computers/adapters/boat.ts b/server/src/modules/computers/adapters/boat.ts index c6a34faaf2..f74f9eb67a 100644 --- a/server/src/modules/computers/adapters/boat.ts +++ b/server/src/modules/computers/adapters/boat.ts @@ -196,7 +196,7 @@ export function boatBackend( return value; } const processProgram = String.raw` -import os,sys,json,fcntl,subprocess +import os,sys,json,fcntl,subprocess,shutil p=json.load(sys.stdin);base='/home/user/.paperclip-owners';os.makedirs(base,exist_ok=True) owner=p['owner'];oid=owner['id'];root=os.path.join(base,oid);os.makedirs(root,exist_ok=True) with open(os.path.join(root,'lock'),'a') as lock: @@ -215,6 +215,12 @@ with open(os.path.join(root,'lock'),'a') as lock: subprocess.run(['systemctl','--user','stop',slice,unit],capture_output=True) state=subprocess.run(['systemctl','--user','show',slice,'--property=ActiveState','--value'],capture_output=True,text=True).stdout.strip() if state not in ('inactive','failed',''):raise RuntimeError('process retirement unconfirmed') + # Reap only this retired owner's command spools after its cgroup is stopped. + # This covers controller crashes and SSH timeouts that skipped caller cleanup. + for entry in os.scandir(root): + if entry.name.startswith('command-') and entry.is_dir(follow_symlinks=False): + try:shutil.rmtree(entry.path) + except FileNotFoundError:pass print('{}') elif p['action']=='inspect': claim=owner.get('process');marker=os.path.join(root,'claim.json');actual=json.load(open(marker)) if os.path.exists(marker) else None diff --git a/server/src/modules/computers/application/service.test.ts b/server/src/modules/computers/application/service.test.ts index 80be803a88..5de8a1d598 100644 --- a/server/src/modules/computers/application/service.test.ts +++ b/server/src/modules/computers/application/service.test.ts @@ -490,6 +490,14 @@ with open(output,'wb') as out,open(error,'wb') as err: cwd: temp, stdin: "foreground-input\n", }); + const stdinProbe = await binding.runner.execute({ + command: "python3", + args: ["-c", "import os,sys; assert os.fstat(0).st_nlink > 0, 'stdin backing file was unlinked before child exit'; assert os.fstat(0).st_mode & 0o777 == 0o600; print(sys.stdin.read(),end='')"], + cwd: temp, + stdin: "persistent-stdin", + }); + expect(stdinProbe.exitCode).toBe(0); + expect(stdinProbe.stdout).toBe("persistent-stdin"); backgroundPid = Number(result.stdout.trim()); expect(result.exitCode).toBe(7); expect(result.stderr).toBe("foreground-input\n"); diff --git a/server/src/modules/computers/application/service.ts b/server/src/modules/computers/application/service.ts index 947506a1c0..292836dee0 100644 --- a/server/src/modules/computers/application/service.ts +++ b/server/src/modules/computers/application/service.ts @@ -209,7 +209,7 @@ export function createComputerService( "-c", String.raw` import os,sys,json,fcntl,subprocess,time,tempfile,shutil -p=json.load(sys.stdin);root='/home/user/.paperclip-owners/'+p['owner'];os.makedirs(root,exist_ok=True) +p=json.load(sys.stdin);os.umask(0o077);root='/home/user/.paperclip-owners/'+p['owner'];os.makedirs(root,exist_ok=True) # A receipt reports foreground completion independently from cgroup lifetime. # ExitType=cgroup keeps nohup/setsid descendants owned until owner retirement. supervisor=""" @@ -220,7 +220,8 @@ os.unlink(payload) i=p['input'] try: with open(p['stdin'],'rb') as source: - os.unlink(p['stdin']) + # Boat's persistent filesystem invalidates unlinked stdin descriptors. + # Keep the private backing file until the command's normal finally cleanup. child=subprocess.Popen([i['command']]+i.get('args',[]),stdin=source) code=child.wait() except Exception as error: