From 2c477fa4ede23fe2cb165af09df2e66a95d482c2 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 c6a34faaf20..f74f9eb67ad 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 80be803a887..5de8a1d5989 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 947506a1c0f..292836dee0d 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: