mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
fix(proc): retain published command job ownership
Problem: Repeated cancellation or shell reaping can discard an established Windows Job Object owner and perform a new PID-based stop attempt. Root cause: TrackedCommand removes its one-shot TrackedJob on the first kill and also starts a redundant PID tree observer after successful job assignment. Fix: Keep the published owner across repeated and late cancellation, and let an established Job Object own its tree. Preserve existing non-job fallbacks. Add native Windows owned-child probes with a harmless taskkill recorder and an exact preceding-owner counterfactual inventory gate. Verification: Root inventory reconciles 239 packages, 24060 test/subtest passes and 215 existing skips. Original Boot command hit its 15m package alarm; all 845 top-level tests close through the recorded disjoint remainder groups without a timeout increase. Complete proc and shell race suites pass. Root vet, full lint, make check and actionlint pass. Windows new/old owners compile and Windows vet passes; actual native regressions remain a hosted gate. No full PID-identity, descendant-quiescence or physical-scope claim.
This commit is contained in:
1 parent
74805c102c
commit
75a36959fd
6 files changed
+366
-6
No files matched your search
@@ -223,6 +223,32 @@ jobs:
|
||||
WINDOWS_SANDBOX_WAIT_MS: "20000"
|
||||
run: go test -count=1 -p 4 -timeout=18m ./internal/runtime/agent/... ./internal/platform/appidentity/... ./internal/assembly/boot/... ./internal/state/checkpoint/... ./internal/frontend/cli/... ./internal/session/control/... ./internal/base/filelock/... ./internal/base/fileutil/... ./internal/ext/hook/... ./internal/state/instruction/... ./internal/ext/mcplaunch/... ./internal/platform/notify/... ./internal/base/proc/... ./internal/platform/remote/... ./internal/platform/repair/... ./internal/safety/sandbox/... ./internal/base/sysproxy/... ./internal/state/workspacelease/... ./cmd/...
|
||||
|
||||
- name: test (Windows published command job owner)
|
||||
if: env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
evidence="$RUNNER_TEMP/published-job-owner"
|
||||
mkdir -p "$evidence"
|
||||
go test -count=1 -timeout=3m -json -run '^TestPublishedJob' ./internal/base/proc | tee "$evidence/positive.jsonl"
|
||||
node scripts/published-job-evidence.mjs positive "$evidence/positive.jsonl"
|
||||
node scripts/published-job-evidence.mjs overlay "$evidence"
|
||||
set +e
|
||||
go test -count=1 -timeout=3m -json -overlay="$evidence/overlay.json" -run '^TestPublishedJob' ./internal/base/proc > "$evidence/negative.jsonl"
|
||||
old_result=$?
|
||||
set -e
|
||||
test "$old_result" -eq 1
|
||||
node scripts/published-job-evidence.mjs negative "$evidence/negative.jsonl"
|
||||
|
||||
- name: upload Windows published command job owner evidence
|
||||
if: always() && env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7
|
||||
with:
|
||||
name: published-job-owner-windows-${{ github.run_attempt }}
|
||||
path: ${{ runner.temp }}/published-job-owner/
|
||||
if-no-files-found: error
|
||||
retention-days: 7
|
||||
|
||||
- name: test (Windows isolation owners)
|
||||
if: env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
|
||||
timeout-minutes: 25
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
---
|
||||
owner: @SivanCola
|
||||
backup: @esengine
|
||||
status: active
|
||||
reviewed: 2026-10-27
|
||||
---
|
||||
|
||||
# Published command Job Object owner / 已发布命令的 Job Object 所有权
|
||||
|
||||
## Behavior / 行为
|
||||
|
||||
Repeated cancellation of a tracked Windows command keeps the same published
|
||||
`TrackedJob`. Its one-shot release closes the Job Object once; later cancellation
|
||||
uses the original Go process object instead of invoking PID-based `taskkill`.
|
||||
A successfully assigned Job Object also replaces the auxiliary PID tree walker.
|
||||
|
||||
Windows 跟踪命令重复取消时保留同一个已发布 `TrackedJob`。Job Object 只关闭一次;
|
||||
后续取消使用原 Go 进程对象,不再因丢失 owner 而调用按 PID 寻址的 `taskkill`。
|
||||
成功分配 Job Object 后,也不再启动额外的 PID 树扫描器。
|
||||
|
||||
The shared owner is `proc.RunCommand`, used by the shell tool. Cancellation,
|
||||
bounded cleanup retries and shell reaping all reach `TrackedCommand.Kill`.
|
||||
|
||||
Keeping the released guard matters after `cmd.Wait`: a PID can eventually be
|
||||
reused after its process object is released. See Microsoft's
|
||||
[process identifier lifetime explanation](https://devblogs.microsoft.com/oldnewthing/20110107-00/?p=11803).
|
||||
|
||||
共享 owner 是 shell 工具调用的 `proc.RunCommand`。取消、有限重试清理和 shell
|
||||
收尾都会调用 `TrackedCommand.Kill`。`cmd.Wait` 后仍须保留已释放的 guard:进程
|
||||
对象释放后,PID 最终可能被重用;不能把数字继续当作原命令身份。
|
||||
|
||||
## Mechanism boundary / 机制边界
|
||||
|
||||
| Route | Contract / 约定 |
|
||||
| --- | --- |
|
||||
| Published Windows Job Object | Keep its one-shot owner; no auxiliary PID walker / 保留一次性 owner,不启动额外 PID 扫描 |
|
||||
| Late published Job Object after cancellation | Retain and release that same owner / 保存并释放同一 owner |
|
||||
| Unix process group or unavailable Windows Job Object | Existing fallback and retry behavior remains / 沿用原回退与重试 |
|
||||
| Untracked command and direct KillTree | Existing PID-based Windows fallback remains / 原按 PID 回退仍在 |
|
||||
| Standalone TreeTracker | Generation refresh, identity checks and termination handles need separate work / 代际刷新、身份检查与终止句柄另需闭环 |
|
||||
| Launch failure before owner publication | Existing cleanup remains / 沿用原启动失败清理 |
|
||||
|
||||
This does not establish complete descendant quiescence or a recovery receipt.
|
||||
Unknown execution scope still needs retention and diagnosis. No permission,
|
||||
configuration, API, persisted format, cancellation budget, model request or
|
||||
cache-stable prefix changes. There is no demonstrated PID-reuse incident here.
|
||||
|
||||
本修改不证明后代进程完全静止,也不产生恢复凭证。执行范围未知时仍须保留资源并
|
||||
诊断。不改权限、配置、API、持久化格式、取消预算、模型请求或缓存稳定前缀。
|
||||
这里没有已复现的 PID 重用或误杀事故。
|
||||
|
||||
## Verification / 验证
|
||||
|
||||
Windows fixtures launch only the test's own helper process through `RunCommand`.
|
||||
A renamed test executable records `taskkill` arguments without terminating any
|
||||
PID.
|
||||
|
||||
After normal or canceled waiting returns the published owner, the probe is
|
||||
reset and repeated reaping must leave no marker. Stop attempts before that
|
||||
returned-owner boundary are outside this assertion. An explicit legacy fallback
|
||||
control must create a marker.
|
||||
|
||||
Windows 回归只通过 `RunCommand` 启动测试自有子进程。重命名的测试程序只记录
|
||||
`taskkill` 参数,不终止任何 PID。正常或取消等待返回已发布 owner 后重置探针,
|
||||
重复收尾不得产生标记;该返回边界之前的停止尝试不在此断言范围。显式旧回退对照
|
||||
必须产生标记。
|
||||
|
||||
The dedicated Windows gate runs unchanged fixtures against the new owner and an
|
||||
overlay reversing the ownership changes. It records the reconstructed SHA-256.
|
||||
|
||||
For this unit that digest must match the exact preceding owner,
|
||||
`61b4e1f88e32322fa287b9d1ae71edeb0cd7f2511e0894cbd811d49794590c3a`,
|
||||
before qualifying its native counterfactual evidence.
|
||||
|
||||
Later unrelated owner edits remain in the reconstruction. Its digest then
|
||||
describes an ownership counterfactual, not this complete historical file.
|
||||
|
||||
It requires six named
|
||||
pass events for the new owner, four expected fail events and two passing controls
|
||||
for the preceding owner, plus each package terminal.
|
||||
|
||||
Parent and helper events are
|
||||
included; these are not six independent cases. Cross-compilation alone does not
|
||||
qualify this native behavior.
|
||||
|
||||
专用 Windows 门禁运行新 owner 与反转所有权修改的 overlay,记录重建文件 SHA-256,
|
||||
测试保持相同。本单元原生反例资格还须核对它等于上述精确旧 owner 的摘要。
|
||||
以后无关 owner 修改保留在重建文件中;届时它是所有权反例,不冒称完整历史文件。
|
||||
|
||||
新版须有六个具名通过事件;反例须有四个预期失败事件和两个通过对照,且均
|
||||
核对包终态。计数包含父测试和辅助测试,不是六个独立用例;交叉编译不能替代原生
|
||||
行为验证。
|
||||
@@ -0,0 +1,176 @@
|
||||
//go:build windows
|
||||
|
||||
package proc
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
const publishedJobMarker = "REASONIX_PUBLISHED_JOB_TASKKILL_MARKER"
|
||||
|
||||
// A renamed copy records a legacy PID fallback without terminating any PID.
|
||||
func TestMain(m *testing.M) {
|
||||
if strings.EqualFold(filepath.Base(os.Args[0]), "taskkill.exe") && os.Getenv(publishedJobMarker) != "" {
|
||||
if err := os.WriteFile(os.Getenv(publishedJobMarker), []byte(strings.Join(os.Args[1:], " ")), 0o600); err != nil {
|
||||
os.Exit(2)
|
||||
}
|
||||
os.Exit(0)
|
||||
}
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
|
||||
func publishedJobStub(t *testing.T) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, err := os.ReadFile(exe)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "taskkill.exe"), data, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
marker := filepath.Join(dir, "fallback.txt")
|
||||
t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH"))
|
||||
t.Setenv(publishedJobMarker, marker)
|
||||
return marker
|
||||
}
|
||||
|
||||
func publishedJobCommand(t *testing.T, ctx context.Context, hold bool) *exec.Cmd {
|
||||
t.Helper()
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cmd := exec.CommandContext(ctx, exe, "-test.run=^TestPublishedJobChild$", "-test.timeout=30s")
|
||||
mode := "return"
|
||||
if hold {
|
||||
mode = "hold"
|
||||
}
|
||||
cmd.Env = append(os.Environ(), "REASONIX_PUBLISHED_JOB_CHILD="+mode)
|
||||
HideWindow(cmd)
|
||||
return cmd
|
||||
}
|
||||
|
||||
func TestPublishedJobChild(t *testing.T) {
|
||||
mode := os.Getenv("REASONIX_PUBLISHED_JOB_CHILD")
|
||||
if mode == "" {
|
||||
return
|
||||
}
|
||||
fmt.Println("PUBLISHED-JOB-READY")
|
||||
if mode == "hold" {
|
||||
_, _ = bufio.NewReader(os.Stdin).ReadString('\n')
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublishedJobNoPIDFallback(t *testing.T) {
|
||||
for _, hold := range []bool{false, true} {
|
||||
name := "after_wait"
|
||||
if hold {
|
||||
name = "cancelled_wait"
|
||||
}
|
||||
t.Run(name, func(t *testing.T) {
|
||||
marker := publishedJobStub(t)
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
defer cancel()
|
||||
cmd := publishedJobCommand(t, ctx, true)
|
||||
input, err := cmd.StdinPipe()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer input.Close()
|
||||
pipe, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if cmd.Process != nil {
|
||||
_ = cmd.Process.Kill()
|
||||
}
|
||||
})
|
||||
type result struct {
|
||||
tracked *TrackedCommand
|
||||
err error
|
||||
}
|
||||
done := make(chan result, 1)
|
||||
go func() { p, e := RunCommand(ctx, cmd, RunOptions{Track: true}); done <- result{p, e} }()
|
||||
line, err := bufio.NewReader(pipe).ReadString('\n')
|
||||
if err != nil || strings.TrimSpace(line) != "PUBLISHED-JOB-READY" {
|
||||
t.Fatalf("child readiness=%q %v", line, err)
|
||||
}
|
||||
if hold {
|
||||
cancel()
|
||||
} else if _, err := fmt.Fprintln(input, "release"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var got result
|
||||
select {
|
||||
case got = <-done:
|
||||
case <-time.After(10 * time.Second):
|
||||
t.Fatal("owned child did not finish")
|
||||
}
|
||||
if hold && got.err == nil {
|
||||
t.Fatalf("cancel=%v", got.err)
|
||||
}
|
||||
if !hold && got.err != nil {
|
||||
t.Fatal(got.err)
|
||||
}
|
||||
if got.tracked == nil || !got.tracked.Diagnostics().JobObjectCreated {
|
||||
t.Fatal("required native fixture has no Job Object")
|
||||
}
|
||||
// The assertion starts with the returned owner, after launch publication.
|
||||
if err := os.Remove(marker); err != nil && !os.IsNotExist(err) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got.tracked.Kill()
|
||||
got.tracked.Kill()
|
||||
if data, err := os.ReadFile(marker); !os.IsNotExist(err) {
|
||||
t.Fatalf("published Job Object fell through to PID taskkill: %q %v", data, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublishedJobUsesKernelTree(t *testing.T) {
|
||||
cmd := publishedJobCommand(t, t.Context(), false)
|
||||
tracked, err := RunCommand(t.Context(), cmd, RunOptions{Track: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if tracked == nil || !tracked.Diagnostics().JobObjectCreated {
|
||||
t.Fatal("required native fixture has no Job Object")
|
||||
}
|
||||
tracked.mu.Lock()
|
||||
owner := tracked.job
|
||||
tracked.mu.Unlock()
|
||||
if owner != nil {
|
||||
t.Cleanup(func() { owner.Kill(cmd) })
|
||||
}
|
||||
if tracked.Diagnostics().TreeTrackerStarted {
|
||||
t.Fatal("owned Job Object also started a PID tree walker")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublishedJobFallbackControl(t *testing.T) {
|
||||
marker := publishedJobStub(t)
|
||||
cmd := publishedJobCommand(t, t.Context(), false)
|
||||
if err := cmd.Run(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
KillTree(cmd)
|
||||
data, err := os.ReadFile(marker)
|
||||
if err != nil || !strings.Contains(string(data), "/PID") {
|
||||
t.Fatalf("legacy control did not reach the recording stub: %q %v", data, err)
|
||||
}
|
||||
}
|
||||
@@ -75,7 +75,9 @@ func RunCommand(ctx context.Context, cmd *exec.Cmd, opts RunOptions) (*TrackedCo
|
||||
return tracked, err
|
||||
}
|
||||
tracked.setStarted(job)
|
||||
tracked.setTree(TrackTree(cmd))
|
||||
if !job.Tracked() {
|
||||
tracked.setTree(TrackTree(cmd))
|
||||
}
|
||||
return tracked, waitForTrackedCommand(ctx, tracked, cmd.Wait, opts.CancelWaitGrace, opts.CancelRetryInterval, opts.CancelRetryFor)
|
||||
}
|
||||
|
||||
@@ -180,10 +182,9 @@ func (p *TrackedCommand) Kill() {
|
||||
p.killed = true
|
||||
p.diag.KillCalls++
|
||||
job := p.job
|
||||
p.job = nil
|
||||
tree := p.tree
|
||||
p.mu.Unlock()
|
||||
if !firstKill {
|
||||
if !firstKill && !job.Tracked() {
|
||||
job = nil
|
||||
}
|
||||
job.Kill(p.cmd)
|
||||
@@ -230,9 +231,7 @@ func (p *TrackedCommand) setStarted(job *TrackedJob) {
|
||||
}
|
||||
p.mu.Lock()
|
||||
killed := p.killed
|
||||
if !killed {
|
||||
p.job = job
|
||||
}
|
||||
p.job = job
|
||||
p.diag.RootPID = rootPID
|
||||
p.diag.Tracked = true
|
||||
p.diag.JobObjectCreated = job.Tracked()
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
Windows 跟踪命令保留已发布的 Job Object owner。
|
||||
|
||||
## 修复
|
||||
|
||||
- 重复取消和收尾时保留原一次性 owner,避免重新调用按 PID 寻址的停止路径。关联 #14025。
|
||||
- 成功分配 Job Object 后不再启动额外 PID 扫描器。关联 #14025。
|
||||
- 无 Job Object、未跟踪命令及独立树追踪的旧边界仍在,不代表完整进程隔离已完成。关联 #14025。
|
||||
|
||||
---
|
||||
|
||||
<!-- lang:en -->
|
||||
|
||||
## Fixed
|
||||
|
||||
- Tracked Windows commands retain their published Job Object owner across repeated cancellation and reaping, avoiding a new PID-based stop attempt. Refs #14025.
|
||||
- A successfully assigned Job Object replaces the auxiliary PID walker. Refs #14025.
|
||||
- Existing non-job, untracked and standalone tree-tracking limits remain; full process isolation is not established by this change. Refs #14025.
|
||||
@@ -0,0 +1,50 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { join, resolve } from 'node:path';
|
||||
|
||||
const required = [
|
||||
'TestPublishedJobChild',
|
||||
'TestPublishedJobNoPIDFallback',
|
||||
'TestPublishedJobNoPIDFallback/after_wait',
|
||||
'TestPublishedJobNoPIDFallback/cancelled_wait',
|
||||
'TestPublishedJobUsesKernelTree',
|
||||
'TestPublishedJobFallbackControl',
|
||||
];
|
||||
const failures = new Set(required.slice(1, 5));
|
||||
const [mode, destination] = process.argv.slice(2);
|
||||
|
||||
if (mode === 'overlay') {
|
||||
const source = resolve('internal/base/proc/run.go');
|
||||
let old = readFileSync(source, 'utf8').replaceAll('\r\n', '\n');
|
||||
for (const [current, preceding] of [
|
||||
['\tif !job.Tracked() {\n\t\ttracked.setTree(TrackTree(cmd))\n\t}', '\ttracked.setTree(TrackTree(cmd))'],
|
||||
['\tjob := p.job\n\ttree := p.tree', '\tjob := p.job\n\tp.job = nil\n\ttree := p.tree'],
|
||||
['\tif !firstKill && !job.Tracked() {', '\tif !firstKill {'],
|
||||
['\tkilled := p.killed\n\tp.job = job', '\tkilled := p.killed\n\tif !killed {\n\t\tp.job = job\n\t}'],
|
||||
]) {
|
||||
if (old.split(current).length !== 2) throw new Error('owner reversal is not unique');
|
||||
old = old.replace(current, preceding);
|
||||
}
|
||||
const sha256 = createHash('sha256').update(old).digest('hex');
|
||||
const dir = resolve(destination);
|
||||
mkdirSync(dir, { recursive: true });
|
||||
const replacement = join(dir, 'run.go');
|
||||
writeFileSync(replacement, old);
|
||||
writeFileSync(join(dir, 'overlay.json'), JSON.stringify({ Replace: { [source]: replacement } }));
|
||||
writeFileSync(join(dir, 'owner-proof.json'), JSON.stringify({ source, sha256 }, null, 2));
|
||||
} else if (mode === 'positive' || mode === 'negative') {
|
||||
const events = readFileSync(destination, 'utf8').trim().split('\n').map(line => JSON.parse(line));
|
||||
const pkg = 'reasonix/internal/base/proc';
|
||||
const terminals = events.filter(e => e.Package === pkg && ['pass', 'fail', 'skip'].includes(e.Action));
|
||||
if (terminals.length !== required.length + 1) throw new Error('unexpected terminal inventory');
|
||||
for (const name of [...required, undefined]) {
|
||||
const entries = terminals.filter(e => e.Test === name);
|
||||
const failed = mode === 'negative' && (name === undefined || failures.has(name));
|
||||
if (entries.length !== 1 || entries[0].Action !== (failed ? 'fail' : 'pass')) {
|
||||
throw new Error(`unexpected terminal for ${name ?? pkg}`);
|
||||
}
|
||||
}
|
||||
if (events.some(e => e.Package !== pkg || e.Action === 'build-fail')) throw new Error('unexpected package or build failure');
|
||||
} else {
|
||||
throw new Error('expected overlay, positive or negative mode');
|
||||
}
|
||||
Reference in new issue
Block a user