Repository navigation
feat(gate): own gated descendants inside Quest scopes and on Windows - #437
Conversation
Quest-Claim: 38e85d7a63c81b45
Quest's systemd scopes are now delegated, so a gate inside one, such as under `quest worktree run`, runs its command in a child cgroup of its own instead of deferring to the scope. The child shares the handshake, kill, and wait of `systemdScopes.run` through a common `contain`, and is removed once empty; the enclosing scope's cgroup.kill still reaches it. A scope from a Quest that predates delegation keeps the old behaviour. On Windows the gate runs its command under a small Bun stub that joins a kill-on-close Job Object forbidding breakaway before it starts the command, so every descendant, detached ones included, stops with it. Closes the quest m2/gate-ownership. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…cgroup paths as POSIX The dispatched Windows run failed: Bun's process.argv for `-e` differs on Windows, so the stub parsed a path as its BUN_BE_BUN JSON, and questScope joined cgroup paths with backslashes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Bun's process.argv for a script passed with -e differs on Windows, so the test compares the stub with running the command directly instead of a fixed argv, and checks the exact argv elsewhere. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Warning Review limit reachedYou've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository. Next included review available in 24 minutes. View limit details
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. CodeRabbit Verify
|
Keeps #429's cancellation semantics in the shared `contain`: a cancelled run signals every process in its cgroup (the scope through systemctl kill, a nested gate's child cgroup through its cgroup.procs), waits until the cgroup empties or a second signal kills it, and binds a scope inside another Quest scope to it. A nested gate, like `inside`, leaves a received signal and the second signal's kill to the enclosing scope's owner, but still lets its child cgroup's processes finish their cleanup before it kills what is left. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…to it alone Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Design change: merging main brought in moq-dev/quest#429, so the (Written by Claude Opus 5.5) |
|
Decisions settled in
Queued at head 928e681. (Written by Claude Opus 5.5) |
Finishes quest/m2/gate-ownership.md: a gate inside a
quest worktree runscope now owns its own subtree, and on Windows a gate owns its command's descendants, not just the command.Summary
systemdScopes.runpasses--property=Delegate=yes, so every Quest scope (quest worktree run's and the gate's own) lets its processes create child cgroups.nestedowner. Inside a delegated Quest scope,quest gatecreates<its cgroup>/quest-gate-<uuid>, runs the command there, and once the command exits itcgroup.kills the child, waits until it is empty, and removes it, along with any empty children a crashed nested gate left. The child sits inside the enclosing scope, so the outer scope'scgroup.killandpopulatedstill cover it. A scope that is not delegated, such as one a running supervisor from an older Quest started, still runsdirect, as before.systemdScopes.runmove intocontain(src/cli/supervise.ts), which bothsystemdScopes.runand the newnestuse. The handshake shell fornestwrites0to the child'scgroup.procsbefore it reports, so nothing it starts runs outside the child.containkeeps fix(gate): cancelling a gate stops its active phase #429's semantics for both owners: a cancelled run signals every process in its cgroup (a scope throughsystemctl kill --kill-whom=all, a nested gate's child cgroup through itscgroup.procs, recursively), waits until the cgroup empties or a second signal kills it, and a scope inside another Quest scope binds to it (BindsTo=). Thenestedowner, like fix(gate): cancelling a gate stops its active phase #429'sinside, leaves a signal it received, and the second signal's kill, to the enclosing scope's owner, which already delivered them to every process in the scope, the child cgroup included. It still lets the child's processes finish their own cleanup before it kills what is left.owner()keepsinsidefor a nested call and for a scope that is not delegated.questScope()replacesscoped()and fix(gate): cancelling a gate stops its active phase #429'senclosingScope(). It finds the innermostquest-*.scopeanywhere in this process's cgroup path, so a gate running in a gate's child cgroup still counts as inside the scope.supervise.tsjoins cgroup paths withnode:path/posix, so they stay POSIX paths on every platform.src/cli/job.ts). Thejobowner runs the command under a small Bun stub (<execPath> -e STUB, withBUN_BE_BUN=1asaccountHomealready uses for a compiled Quest). The command and the caller'sBUN_BE_BUNreach the stub as JSON inQUEST_GATE_JOB, which it removes before the command starts, because Bun'sprocess.argvfor-ediffers on Windows. Throughbun:ffithe stub creates a job withJOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSEand no breakaway flag, joins it, and only then starts the command. Per Nested Jobs, a child that breaks away from libuv's silent-breakaway job moves up the chain only until it reaches a job that forbids breakaway. So every descendant, detached ones included, stays in the stub's job. The job dies with the stub: when the command exits, when the gate terminates the stub (a second Ctrl-C), or when the gate dies and libuv's own kill-on-close job ends the stub.gateandworktree run, the README's gate and worktree sections, andscripts/gate-check.sh's header describe the new ownership.Choices made without asking
The stub joins the job itself, instead of the gate assigning the command after spawning it. Node and Bun cannot spawn a process suspended, so assigning after spawn would leave a window in which a grandchild escapes. Having the gate join a job itself would avoid that window, but the gate could then never kill leftovers without killing itself, and libuv aborts the process on some
AssignProcessToJobObjectfailures. The stub costs one extra Bun start per gated command on Windows.Delegation applies to every Quest scope, the gate's own included, not just
quest worktree run's. One code path gives one behaviour: a gate whose environment lostQUEST_GATE_ADMITTEDinside a gate scope also owns a child cgroup instead of escaping to a sibling scope. On this host the user slices already enablecpu memory pids, so delegation enables no new controller.If the stub cannot create or join the job (for example, under a parent job with UI restrictions), it prints
quest gate: cannot own descendants of the command: …and runs the command anyway, owning only the command, as before. This matches Linux settling for a process group without systemd.On Windows, a command that cannot start exits 127 with the error on stderr, as a shell and the systemd scope owner already do.
directrejects instead.The caller's
BUN_BE_BUNis restored before the command starts, because a nested compiled Quest would otherwise run as Bun.A received signal reaches a nested gate's command only through the enclosing owner (merge resolution). This PR's
nestedowner first forwarded each received signal to its command, but fix(gate): cancelling a gate stops its active phase #429, merged since, holds a received signal back inside any Quest scope: the enclosing owner already delivered it to every process there, and a nested Quest treats a second delivery as a kill.nestedfollows fix(gate): cancelling a gate stops its active phase #429. So, as with fix(gate): cancelling a gate stops its active phase #429'sinside, a signal sent withkillto such a gate alone does not reach its command, and a second one does not kill it;scripts/gate-check.sh, which sends one, therefore runs only outside every Quest scope, as its header now says.Verification
just checkandjust testpass on Linux (1001 CLI and core tests, plus the Worker suite).nestedgate once, and the nested gate lets the leaf finish its cleanup instead of killing it once Just exits.nestedrun signals every process in its child cgroup, including one only a shell'swaitholds.owner()picksinsidefor a nested call or child cgroup in a Quest scope and for a scope that is not delegated.questScopeparsing, including child cgroups.nested: the command's cgroup is…/quest-gate-<uuid>.scope/quest-gate-<uuid>, a descendant it left is stopped before the gate returns, and the child cgroup is removed.direct.BUN_BE_BUNand dropsQUEST_GATE_JOB, passes arguments (spaces, quotes, backslashes, an empty one) asdirectdoes, and returns 127 for a missing command (these run on every platform).job, and a detached descendant stops both after the command exits and after a kill.scripts/gate-check.shpasses on its own. Before the merge it also passed inside a hand-madeDelegate=yesquest-worktree-*.scope, where the gated command reported…/quest-worktree-<uuid>.scope/quest-gate-<uuid>. After the merge, its exit-status and orphan checks still pass there, and its cancel check waits as described above.dist/questruns the stub withBUN_BE_BUN=1, passes the exit status through, and clears bothBUN_BE_BUNandQUEST_GATE_JOBfor the command.windows-2022andwindows-11-arm. On Windows the stub read a path where it expected theBUN_BE_BUNJSON, andquestScopejoined cgroup paths with backslashes. After the fixes, run 41661583517 on b78e6cc passes every job, both native Windows jobs included, so the Job Object tests (a detached descendant stops after exit and after a kill) pass on x64 and ARM64. After merging main, run 41687335580 on 928e681 passestest/gate.test.ts(Job Object tests included) on bothwindows-2022andwindows-11-arm. Both native Windows jobs still fail, on 4 tests that main brought in, which this PR does not touch. Main runs its native Windows jobs only on dispatch, so it never caught them:test/move.test.ts's threequest check --fixlink-drop tests expect LF where Windows writes CRLF (moq-dev/quest#427), andtest/runner.test.ts'sreviewerFortest expects a:-joinedPATHwhere Windows joins it with;.Review
quest review), gpt-6.1-sol, medium effort, round 2 of session 01a19f93 (first round on b78e6cc, which found nothing), head 6c5b95c, base origin/main b02af47.Follow-ups
test/move.test.ts(quest check --fixlink drops, from moq-dev/quest#427) and thePATHdelimiter intest/runner.test.ts'sreviewerFortest. Neither is related to gate ownership. Native Windows runs only onworkflow_dispatch, so main never caught them (run 41687335580).🤖 Generated with Claude Code
(Written by Claude Opus 5.5)