mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 00:57:45 +00:00
* refactor(daemon): rename the PTY process ceiling modules to terminal scopes Pure move ahead of making per-terminal scopes the Linux default. * fix(linux): run each terminal in its own systemd scope The daemon and every terminal shared one systemd scope, so one kernel OOM kill stopped the whole scope, systemd-oomd killed every terminal at once, and all terminals shared one TasksMax. When the daemon runs in the fresh scope its launcher made, each terminal now gets its own transient scope under a slice bound to the daemon's scope, as the opt-in process ceiling already did. OOMPolicy=continue is used where a probe scope accepts it, the ceiling becomes TasksMax per terminal, and oomd is asked to avoid the daemon's scope. The slice's binding replaces the death watch's slice argument and the exit-time slice stop. * test(linux): cover per-terminal scopes in the daemon scope Docker harness Runs on systemd 249, 252 and 257: an OOM kill in one terminal leaves the daemon and the other terminals running, the process ceiling applies per terminal, and stopping the daemon scope or SIGKILLing the daemon reaps the slice and every terminal. The baseline reports today's whole-daemon kill. * chore: prune the sync-spawn baseline after removing the exit-time slice stop * fix(linux): read the terminal scope wrappers as no foreground process yet systemd-run and env own a new terminal until the shell execs, which took up to ~270 ms per terminal when 50 started at once on Ubuntu 24.04. Reported as the foreground, they read as running work and could pass the four-poll child-process readiness gates before the shell existed. * fix(linux): simplify terminal scope state and keep scopes without pids delegation One nullable runtime replaces the unread ceiling state; losing the slice logs once and later terminals stay in the daemon scope. The probe no longer fails when pids.max is absent, so only the ceiling turns off there. The resolution is one on/off value, the launch id comes from the daemon scope alone, and the ManagedOOMPreference request no longer blocks startup. The foreground filter matches Orca's exact wrapper paths, so a user-run systemd-run stays visible. * refactor(linux): name the terminal slice from the daemon scope and start the oomd hint at init The slice and scope names now come from the daemon scope's own unit name, so nothing parses its prefix back out. The ManagedOOMPreference request moves out of the resolver into initializeTerminalScopes, where a spawn error can no longer land in the resolver's catch after the slice exists. * test(linux): run the terminal scope wrapper foreground test off Windows only * test(linux): tolerate an already unloaded scope after the baseline OOM stop * fix(linux): read Orca's scope wrapper as the shell, and bound startup systemd calls node-pty names the wrapper path both while it execs the shell and as its spawn file once the foreground group's leader has exited (true | claude, seq | less). Hiding it skipped the agent scan; reporting the shell instead matches what the unwrapped spawn reports. Each startup systemd call now times out after 1.5 s, so a wedged user manager turns terminal scopes off inside the launcher's 10 s wait instead of failing the launch. * fix(linux): mark a timed-out terminal scope setup in the daemon log A busy user manager and one that refused both reported slice_unavailable. The log-only off detail now carries timedOut: true for the first; the reported ceiling reason and the wire are unchanged.
198 lines
5.6 KiB
JavaScript
198 lines
5.6 KiB
JavaScript
import { execFileSync, spawnSync } from 'node:child_process'
|
|
import { existsSync, mkdtempSync, rmSync } from 'node:fs'
|
|
import { tmpdir } from 'node:os'
|
|
import { delimiter, dirname, join, resolve } from 'node:path'
|
|
import { fileURLToPath } from 'node:url'
|
|
import { build } from 'esbuild'
|
|
|
|
const repo = resolve(fileURLToPath(new URL('../..', import.meta.url)))
|
|
const docker =
|
|
process.env.ORCA_DOCKER ??
|
|
(existsSync('/Applications/Docker.app/Contents/Resources/bin/docker')
|
|
? '/Applications/Docker.app/Contents/Resources/bin/docker'
|
|
: 'docker')
|
|
const dockerDirectory = dirname(docker)
|
|
const dockerEnv = {
|
|
...process.env,
|
|
ORCA_BACKGROUND_LAUNCH: '1',
|
|
...(dockerDirectory !== '.'
|
|
? { PATH: `${dockerDirectory}${delimiter}${process.env.PATH ?? ''}` }
|
|
: {})
|
|
}
|
|
const args = process.argv.slice(2)
|
|
if (args.length !== 2 || args[0] !== '--baseline' || !args[1] || args[1].startsWith('-')) {
|
|
throw new Error('Usage: run-daemon-scope-lifetime-docker.mjs --baseline <git-ref>')
|
|
}
|
|
const baselineRef = args[1]
|
|
const dockerDir = join(repo, 'config/docker/daemon-scope-lifetime')
|
|
const temp = mkdtempSync(join(tmpdir(), 'orca-daemon-scope-lifetime-'))
|
|
const suffix = `${process.pid}-${Date.now()}`
|
|
const image = `orca-daemon-scope-lifetime:${suffix}`
|
|
const container = `orca-daemon-scope-lifetime-${suffix}`
|
|
const platform =
|
|
process.env.ORCA_DOCKER_PLATFORM ?? (process.arch === 'arm64' ? 'linux/arm64' : 'linux/amd64')
|
|
|
|
function runDocker(command, allowFailure = false) {
|
|
const result = spawnSync(docker, command, {
|
|
cwd: repo,
|
|
encoding: 'utf8',
|
|
maxBuffer: 16 * 1024 * 1024,
|
|
env: dockerEnv
|
|
})
|
|
if (result.error) {
|
|
throw result.error
|
|
}
|
|
if (result.status !== 0 && !allowFailure) {
|
|
process.stdout.write(result.stdout ?? '')
|
|
process.stderr.write(result.stderr ?? '')
|
|
throw new Error(`docker ${command[0]} failed with ${result.status}`)
|
|
}
|
|
return result
|
|
}
|
|
|
|
async function bundle(mode) {
|
|
const productionScopePath = join(repo, 'src/main/daemon/daemon-cgroup-scope.ts')
|
|
const baselineSource =
|
|
mode === 'baseline'
|
|
? execFileSync('git', ['show', `${baselineRef}:src/main/daemon/daemon-cgroup-scope.ts`], {
|
|
cwd: repo,
|
|
encoding: 'utf8'
|
|
})
|
|
: null
|
|
await build({
|
|
...(mode === 'candidate'
|
|
? { entryPoints: [join(dockerDir, 'bundle-entry.ts')] }
|
|
: {
|
|
stdin: {
|
|
contents:
|
|
"export { buildDurableDaemonScopeCommand } from './src/main/daemon/daemon-cgroup-scope'",
|
|
resolveDir: repo,
|
|
loader: 'ts'
|
|
}
|
|
}),
|
|
bundle: true,
|
|
platform: 'node',
|
|
format: 'cjs',
|
|
target: 'node22',
|
|
outfile: join(temp, `${mode}.cjs`),
|
|
logLevel: 'warning',
|
|
plugins:
|
|
baselineSource === null
|
|
? []
|
|
: [
|
|
{
|
|
name: 'baseline-daemon-scope',
|
|
setup(buildApi) {
|
|
buildApi.onLoad({ filter: /daemon-cgroup-scope\.ts$/ }, ({ path }) =>
|
|
path === productionScopePath
|
|
? { contents: baselineSource, loader: 'ts', resolveDir: dirname(path) }
|
|
: undefined
|
|
)
|
|
}
|
|
}
|
|
]
|
|
})
|
|
}
|
|
|
|
// Why three releases: OOMPolicy on scopes is rejected by systemd 249 (Ubuntu 22.04), accepted by
|
|
// Debian's 252 and native from 253 (Debian 13's 257).
|
|
const baseImages = (
|
|
process.env.ORCA_DAEMON_SCOPE_BASE_IMAGES ??
|
|
'ubuntu:22.04,debian:bookworm-slim,debian:trixie-slim'
|
|
).split(',')
|
|
|
|
function runCases(baseImage) {
|
|
runDocker([
|
|
'build',
|
|
'--platform',
|
|
platform,
|
|
'--build-arg',
|
|
`BASE_IMAGE=${baseImage}`,
|
|
'-t',
|
|
image,
|
|
dockerDir
|
|
])
|
|
runDocker([
|
|
'run',
|
|
'--detach',
|
|
'--name',
|
|
container,
|
|
'--platform',
|
|
platform,
|
|
'--privileged',
|
|
'--cgroupns=host',
|
|
'--security-opt',
|
|
'label=disable',
|
|
'--tmpfs',
|
|
'/run',
|
|
'--tmpfs',
|
|
'/run/lock',
|
|
'-v',
|
|
'/sys/fs/cgroup:/sys/fs/cgroup:rw',
|
|
'-e',
|
|
'ORCA_BACKGROUND_LAUNCH=1',
|
|
image
|
|
])
|
|
runDocker(['cp', `${temp}/.`, `${container}:/opt/daemon-scope-lifetime/`])
|
|
runDocker([
|
|
'exec',
|
|
container,
|
|
'/bin/sh',
|
|
'-c',
|
|
[
|
|
'systemctl start systemd-logind.service',
|
|
'loginctl enable-linger orca-repro',
|
|
'systemctl start user@1100.service',
|
|
'test -S /run/user/1100/bus'
|
|
].join(' && ')
|
|
])
|
|
for (const mode of ['baseline', 'candidate']) {
|
|
const result = runDocker(
|
|
[
|
|
'exec',
|
|
container,
|
|
'timeout',
|
|
'--kill-after=5s',
|
|
'240s',
|
|
'runuser',
|
|
'-u',
|
|
'orca-repro',
|
|
'--',
|
|
'env',
|
|
'XDG_RUNTIME_DIR=/run/user/1100',
|
|
'ORCA_BACKGROUND_LAUNCH=1',
|
|
'node',
|
|
'/opt/daemon-scope-lifetime/run-cases.cjs',
|
|
`/opt/daemon-scope-lifetime/${mode}.cjs`,
|
|
mode
|
|
],
|
|
true
|
|
)
|
|
process.stdout.write(result.stdout ?? '')
|
|
process.stderr.write(result.stderr ?? '')
|
|
if (result.status !== 0) {
|
|
throw new Error(`${mode} scope lifetime oracle failed on ${baseImage} with ${result.status}`)
|
|
}
|
|
}
|
|
}
|
|
|
|
try {
|
|
await bundle('baseline')
|
|
await bundle('candidate')
|
|
for (const baseImage of baseImages) {
|
|
console.log(`== ${baseImage}`)
|
|
try {
|
|
runCases(baseImage)
|
|
} finally {
|
|
runDocker(['rm', '--force', container], true)
|
|
}
|
|
}
|
|
console.log(
|
|
'Linux systemd scope lifetime oracle passed: baseline accumulates orphans; candidate reaps them; live and unrelated work survives; each terminal is its own scope.'
|
|
)
|
|
} finally {
|
|
runDocker(['rm', '--force', container], true)
|
|
runDocker(['image', 'rm', image], true)
|
|
rmSync(temp, { recursive: true, force: true })
|
|
}
|