diff --git a/src/main/daemon/daemon-rttime-limit.test.ts b/src/main/daemon/daemon-rttime-limit.test.ts new file mode 100644 index 00000000000..0e23126d51e --- /dev/null +++ b/src/main/daemon/daemon-rttime-limit.test.ts @@ -0,0 +1,60 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { warnWhenRealtimeTimeoutIsZero } from './daemon-rttime-limit' + +const dirs: string[] = [] +afterEach(() => { + for (const dir of dirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }) + } +}) + +function procLimits(soft: string, hard: string): string { + const dir = mkdtempSync(join(tmpdir(), 'orca-limits-')) + dirs.push(dir) + const path = join(dir, 'limits') + writeFileSync( + path, + [ + 'Limit Soft Limit Hard Limit Units ', + 'Max realtime priority 0 0 ', + `Max realtime timeout ${soft.padEnd(21)}${hard.padEnd(21)}us `, + '' + ].join('\n') + ) + return path +} + +describe('warnWhenRealtimeTimeoutIsZero', () => { + it.each([ + ['0', '0'], + ['0', '200000'] + ])('warns with a remedy hint for soft %s and hard %s', (soft, hard) => { + const log = vi.fn() + warnWhenRealtimeTimeoutIsZero(log, 'linux', procLimits(soft, hard)) + expect(log).toHaveBeenCalledWith('rttime-limit-zero', { + soft, + hard, + hint: expect.stringContaining('RealtimeKit') + }) + }) + + it.each([ + ['unlimited', 'unlimited'], + ['200000', '200000'] + ])('stays quiet for soft %s and hard %s', (soft, hard) => { + const log = vi.fn() + warnWhenRealtimeTimeoutIsZero(log, 'linux', procLimits(soft, hard)) + expect(log).not.toHaveBeenCalled() + }) + + it('stays quiet when the limits file is unreadable or off Linux', () => { + const log = vi.fn() + warnWhenRealtimeTimeoutIsZero(log, 'linux', '/definitely/missing') + warnWhenRealtimeTimeoutIsZero(log, 'darwin', procLimits('0', '0')) + warnWhenRealtimeTimeoutIsZero(log, 'win32', procLimits('0', '0')) + expect(log).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/daemon/daemon-rttime-limit.ts b/src/main/daemon/daemon-rttime-limit.ts new file mode 100644 index 00000000000..375e8d79245 --- /dev/null +++ b/src/main/daemon/daemon-rttime-limit.ts @@ -0,0 +1,30 @@ +import { readFileSync } from 'node:fs' + +// Why: PTYs inherit the daemon's RLIMIT_RTTIME; at 0 the kernel kills timer-armed jobs (SIGXCPU soft, SIGKILL hard) and unprivileged code can't raise the hard limit (#29745). +export function warnWhenRealtimeTimeoutIsZero( + log: (event: string, details?: Record) => void, + platform: NodeJS.Platform = process.platform, + limitsPath = '/proc/self/limits' +): void { + if (platform !== 'linux') { + return + } + let limits: string + try { + limits = readFileSync(limitsPath, 'utf8') + } catch { + return + } + const [, soft, hard] = /^Max realtime timeout\s+(\S+)\s+(\S+)/m.exec(limits) ?? [] + if (soft !== '0' && hard !== '0') { + return + } + log('rttime-limit-zero', { + soft, + hard, + hint: + 'Terminals inherit a zero RLIMIT_RTTIME, so the kernel may kill their jobs (exit 137 or 152). ' + + 'Installing and enabling RealtimeKit (rtkit), then rebooting, often restores a nonzero limit; ' + + 'if this keeps appearing, check the launcher, desktop session, or systemd unit (LimitRTTIME) that starts Orca.' + }) +} diff --git a/src/main/daemon/terminal-scopes-runtime.ts b/src/main/daemon/terminal-scopes-runtime.ts index 7f0cfa6cc2f..70e65b16a2a 100644 --- a/src/main/daemon/terminal-scopes-runtime.ts +++ b/src/main/daemon/terminal-scopes-runtime.ts @@ -1,5 +1,6 @@ import { existsSync } from 'node:fs' import { userManagerEnv } from './daemon-cgroup-scope' +import { warnWhenRealtimeTimeoutIsZero } from './daemon-rttime-limit' import { setPtyActiveProcessLimit } from '../windows/windows-pty-job' import { resolveTerminalScopes, type TerminalScopeRuntime } from './terminal-scopes' import type { PtyProcessCeilingState } from '../../shared/pty-process-ceiling-state' @@ -33,6 +34,7 @@ export function initializeTerminalScopes( // Report requested limits even on hosts that cannot enforce them. log('terminal-memory-limits', { platform: process.platform, ...resolved.memory }) } + warnWhenRealtimeTimeoutIsZero(log) return resolved.ceiling }