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: