From 5ac1f502b1d0466e6841d36cbed4437193e0efc3 Mon Sep 17 00:00:00 2001 From: zayJu Date: Thu, 12 Nov 2026 11:15:40 +0800 Subject: [PATCH 1/3] =?UTF-8?q?refactor(engine):=20=E5=81=9C=E6=AD=A2?= =?UTF-8?q?=E7=9B=AE=E6=A0=87=E4=B8=8E=E9=80=89=E9=A1=B9=E6=8C=89=E9=80=89?= =?UTF-8?q?=E6=8B=A9=E3=80=81=E7=AD=96=E7=95=A5=E5=92=8C=E8=A7=82=E6=B5=8B?= =?UTF-8?q?=E5=88=86=E7=BB=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Target 把选择器与出生声明分开,Options 分成「覆盖范围 / 停止策略 / 观测」,unit 按选择、停止方式、锚点、身份证据和结果分组;engine 的 依赖按时间与存活、进程事实、信号适配器分组。 行为不变:目标解析、进程组优先、子先于父的排序和升级等待都保持原样。 Co-Authored-By: Claude Code --- engine/internal/killer/escalate.go | 19 ++-- engine/internal/killer/killer.go | 144 ++++++++++++++++------------- 2 files changed, 86 insertions(+), 77 deletions(-) diff --git a/engine/internal/killer/escalate.go b/engine/internal/killer/escalate.go index ced47d2c..c2439f28 100644 --- a/engine/internal/killer/escalate.go +++ b/engine/internal/killer/escalate.go @@ -2,6 +2,7 @@ package killer import ( "context" + "slices" "github.com/sheathedsharp/option-berth/engine/internal/state" ) @@ -110,30 +111,24 @@ func noteCancellation(ctx context.Context, pending []*unit) bool { func (e *engine) stillUp(units, scratch []*unit) []*unit { out := scratch[:0] for _, u := range units { - if u.port > 0 { - if e.probe(u.port, u.bind) != portClosed { - out = append(out, u) - } + portUnit := u.port > 0 + // A timed-out probe proves nothing; only a refusal means gone. + if portUnit && e.probe(u.port, u.bind) == portClosed { continue } - if e.anyAlive(u) { + if portUnit || e.anyAlive(u) { out = append(out, u) } } return out } -// anyAlive reports whether any process of the unit is still running. +// anyAlive answers whether the unit still has a process running. func (e *engine) anyAlive(u *unit) bool { if u.pgid != 0 { return e.alive(u.pgid) } - for _, pid := range u.pids { - if e.alive(pid) { - return true - } - } - return false + return slices.ContainsFunc(u.pids, e.alive) } // hardKill sends SIGKILL to whatever of the unit is left, keeping the diff --git a/engine/internal/killer/killer.go b/engine/internal/killer/killer.go index c803ffbe..b5bd1e04 100644 --- a/engine/internal/killer/killer.go +++ b/engine/internal/killer/killer.go @@ -46,18 +46,21 @@ const pollInterval = 100 * time.Millisecond // Target names one thing to stop. Exactly one of Port, PID and RunID is set; // BindAddress only disambiguates a Port bound to several addresses. // -// StartedAt is an optional identity claim on a PID target: when the caller -// believes the process started. Before signalling, the killer checks that the -// live process started no later than that, so a recycled pid is reported rather -// than killed. Zero means no claim — an explicit `kill --pid` says "this pid, -// now". +// A PID target may carry a birth claim: before signalling, the killer checks +// that the live process started no later than StartedAt, so a recycled pid is +// reported instead of killed. Zero means no claim — an explicit `kill --pid` +// says "this pid, now". type Target struct { + // Exactly one selector is set; BindAddress only narrows a Port down when + // several addresses carry the same number. Port int BindAddress string PID int RunID string - StartedAt time.Time - // Name carries the caller's service label, never a selector or identity claim. + + // StartedAt is a birth claim that belongs to a PID selector only. + StartedAt time.Time + // Name is the caller's label for the row; it never selects or claims. Name string } @@ -65,23 +68,18 @@ type Target struct { // default: SIGTERM, escalate to SIGKILL after DefaultGrace, and no tree walk // beyond the one an engine-started run already implies. type Options struct { - // Tree signals the target's whole process tree, not just the listener. - // A listener attributed to a option-berth-started run is always treated as a - // tree, with or without this flag. - Tree bool - // Force sends SIGKILL immediately and skips the escalation wait. - Force bool - // Grace is how long to wait after SIGTERM before escalating. Zero means - // DefaultGrace. - Grace time.Duration - // Escalate opts out of SIGTERM → SIGKILL escalation when set to false. - // Nil means the default, which is to escalate. - Escalate *bool + // What the request covers. A listener attributed to an option-berth-started + // run is stopped as a tree even when Tree is false. + Tree bool + Ports []ports.ListeningPort // pre-collected scan; nil means scan here + + // How it stops. Force is already the strongest signal, so it skips the wait. + Force bool + Grace time.Duration // SIGTERM → SIGKILL window; zero means DefaultGrace + Escalate *bool // false opts out of escalation; nil means escalate + // DryRun plans the actions and performs none of them. DryRun bool - // Ports is an optional pre-collected scan (enriched with Docker and - // process information). When nil, KillPorts takes its own scan. - Ports []ports.ListeningPort // OnSignal receives the unit's root PID synchronously after a process, // group or native-tree signal adapter returned success. It is not proof // of exit. Dry runs, refused identities and failed signals never notify. @@ -144,22 +142,27 @@ func (realClock) Wait(ctx context.Context, d time.Duration) bool { // engine holds the machine-facing dependencies so tests can substitute them. type engine struct { - ctx context.Context - tableErr error - table ProcessTable - clock Clock + ctx context.Context + // Time and liveness: the wait between escalation probes, what is still + // running, and when it was born (the recycle check on claimed pids). + clock Clock + alive func(pid int) bool + startTime func(pid int) (time.Time, bool) + // Process facts: the table the request resolved against, and how to find + // the group a pid leads. + table ProcessTable + tableErr error + groupOf func(pid int) (int, bool) + // Signal adapters, one per kind of subject. signalProc func(pid int, force bool) error signalGrp func(pgid int, force bool) error signalTree func(pid int, force bool) error - groupOf func(pid int) (int, bool) - alive func(pid int) bool - // startTime reports when the process behind a pid started, for the - // recycle check on identity-claiming targets (ports.ProcessStartFreshContext). - startTime func(pid int) (time.Time, bool) + // The ends of the request: a port probe, a container stop, and the observer + // told about every accepted signal. probe func(port int, bind string) portState dockerStop func(container string) error - nativeTree bool onSignal func(int) + nativeTree bool } func newEngine(ctx context.Context) *engine { @@ -254,20 +257,31 @@ func scanPortsContext(ctx context.Context) ([]ports.ListeningPort, error) { // afterwards is stops: what the run plans to do to this unit and what came of // it. A refused target is a unit too, with a refusal and no stops to run. type unit struct { - tree bool - claims map[int]time.Time // recorded run/listener evidence, never replaced - observed map[int]time.Time // live births captured for this plan only + // Selection and naming. port int bind string name string container string // non-empty for a Docker target - pgid int // non-zero when the whole process group is signalled - pids []int // children-first order; ignored when pgid or container is set - root int // the listener (or run root) this unit is anchored on - listenPID int // the pid actually holding the socket, when there is one - depth int // ancestry depth of root, used to order units children-first - refusal error // resolution said no: one failed row, nothing signalled - stops []stop // one per planned action, filled by plan, settled by the run + + // How it is stopped: a whole group, or a children-first pid list. + pgid int + pids []int + + // Where it is anchored and how deep that sits in the process tree. + root int + listenPID int + depth int + tree bool + + // Identity evidence for this plan. claims are recorded run/listener + // evidence and are never replaced by a fresher observation. + claims map[int]time.Time + observed map[int]time.Time + + // Outcome: a refusal is one failed row and nothing signalled; otherwise one + // stop per planned action. + refusal error + stops []stop } // stop is one action the run planned against a unit, with its outcome. A group @@ -443,7 +457,7 @@ func (e *engine) unitsForPID(t Target, snapshot []ports.ListeningPort, opts Opti return refusing(&unit{root: t.PID}, codedf(CodeNotFound, "", "no process with PID %d", t.PID)) } u := &unit{root: t.PID, name: e.table.Name(t.PID), listenPID: t.PID} - e.fillProcesses(u, t.PID, opts.Tree) + e.fillProcesses(u, opts.Tree) u.addClaim(t.PID, t.StartedAt) return []*unit{u} } @@ -495,18 +509,18 @@ func (e *engine) unitFor(lp *ports.ListeningPort, opts Options) *unit { return u } u.root = root - if pgid, ok := e.groupOf(root); ok { - u.pgid = pgid - u.depth = len(e.table.Ancestors(root)) + if pgid, leadsGroup := e.groupOf(root); leadsGroup { + u.pgid, u.depth = pgid, len(e.table.Ancestors(root)) return u } - e.fillProcesses(u, root, tree) + e.fillProcesses(u, tree) return u } // fillProcesses records the pids this unit will signal, children first. -func (e *engine) fillProcesses(u *unit, root int, tree bool) { - u.root, u.tree = root, tree +func (e *engine) fillProcesses(u *unit, tree bool) { + root := u.root + u.tree = tree if tree && !e.nativeTree && e.tableErr != nil { u.refusal = e.tableErr return @@ -571,9 +585,8 @@ func unclaimed(pids []int, claimed map[int]bool) []int { return fresh } -// order sorts units so a unit rooted deeper in the process tree runs before its -// own ancestor: children before parents across targets, not just within one. -// The sort is stable, so unrelated targets keep the caller's order. +// order runs a deeper unit before the ancestor it belongs to, across targets as +// well as within one. Stability keeps unrelated targets in the caller's order. func (e *engine) order(units []*unit) { sort.SliceStable(units, func(i, j int) bool { return units[i].depth > units[j].depth }) } @@ -656,23 +669,22 @@ func (e *engine) execute(u *unit, opts Options) { } } -// signalMethod names the signal a non-escalated kill sends. +// signalMethod is the method recorded for a stop that did not escalate. func signalMethod(force bool) state.KillMethod { - if force { - return state.MethodSIGKILL + if !force { + return state.MethodSIGTERM } - return state.MethodSIGTERM + return state.MethodSIGKILL } -// matchPort returns the scanned listeners on a port, filtered by bind address -// when one was given. +// matchPort returns the scanned listeners on a port; a non-empty bind address +// keeps only the rows bound to it. func matchPort(snapshot []ports.ListeningPort, port int, bind string) []*ports.ListeningPort { var out []*ports.ListeningPort for i := range snapshot { - if snapshot[i].Port != port { - continue - } - if bind != "" && snapshot[i].BindAddress != bind { + wrongPort := snapshot[i].Port != port + wrongBind := bind != "" && snapshot[i].BindAddress != bind + if wrongPort || wrongBind { continue } out = append(out, &snapshot[i]) @@ -713,12 +725,14 @@ func probePort(port int, bind string) portState { // tested without a machine that can produce a hung listener on demand. func probeWith(dial func(network, address string, timeout time.Duration) (net.Conn, error), port int, bind string) portState { - host := bind - switch host { + host := "127.0.0.1" + switch bind { case "", "0.0.0.0", "*": - host = "127.0.0.1" + // A wildcard listener is reachable on loopback. case "::", "[::]": host = "::1" + default: + host = bind } conn, err := dial("tcp", net.JoinHostPort(host, strconv.Itoa(port)), probeTimeout) if err == nil { From 9796a4399ff3f1117a175e5bd077bf9cbc64d339 Mon Sep 17 00:00:00 2001 From: zayJu Date: Thu, 12 Nov 2026 11:15:40 +0800 Subject: [PATCH 2/3] =?UTF-8?q?test(engine):=20killer=20=E7=9A=84=E5=81=87?= =?UTF-8?q?=E6=9C=BA=E5=99=A8=E4=B8=8E=E7=9C=9F=E5=AE=9E=E8=BF=9B=E7=A8=8B?= =?UTF-8?q?=E7=94=A8=E4=BE=8B=E6=8C=89=E5=8A=A8=E4=BD=9C=E9=87=8D=E8=BF=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 假时钟与信号记录重新组织,断言改用 slices 比较;真实进程夹具把 helper 的启动、pid 汇报与子进程派生拆开,进程树和忽略 SIGTERM 的用例覆盖不变。 Co-Authored-By: Claude Code --- engine/internal/killer/integration_test.go | 144 +++++++++++---------- engine/internal/killer/killer_test.go | 126 +++++++++--------- 2 files changed, 133 insertions(+), 137 deletions(-) diff --git a/engine/internal/killer/integration_test.go b/engine/internal/killer/integration_test.go index cf89fffb..92990cc2 100644 --- a/engine/internal/killer/integration_test.go +++ b/engine/internal/killer/integration_test.go @@ -9,6 +9,7 @@ import ( "os/exec" "os/signal" "path/filepath" + "slices" "strconv" "strings" "syscall" @@ -53,40 +54,32 @@ func TestMain(m *testing.M) { os.Exit(testenv.Run(m)) } -// runHelper listens on port, optionally spawns a child helper on childPort, and -// then blocks until it is signalled. With ignoreTerm it plays the wedged dev -// server that only SIGKILL can stop; otherwise the default SIGTERM disposition -// is what a well-behaved one gives us. -// -// Every helper reports its own pid into pidDir, named after the port it serves. -// A helper knows its pid and its port and nothing else, so that name is the one -// thing it can write without being told; a parent spawning a child therefore -// does not have to pass an identity down. +// runHelper is the listener a test starts by re-executing this binary. It +// serves its port until it is signalled, and with ignoreTerm it plays the wedged +// dev server only SIGKILL can stop. Before serving it writes its own pid into +// pidDir under the port's name, because a helper knows its pid and its port and +// nothing else; that lets a parent spawn a child without passing an identity +// down. func runHelper(port, childPort, pidDir string, ignoreTerm bool) { if ignoreTerm { signal.Ignore(syscall.SIGTERM) } - ln, err := net.Listen("tcp", "127.0.0.1:"+port) + sock, err := net.Listen("tcp", "127.0.0.1:"+port) if err != nil { os.Exit(1) } - defer ln.Close() - // Report this process before serving, so a test never races the answer. + defer sock.Close() if pidDir != "" { - if err := os.WriteFile(pidFileFor(pidDir, port), []byte(strconv.Itoa(os.Getpid())), 0o600); err != nil { + pid := strconv.Itoa(os.Getpid()) + if err := os.WriteFile(pidFileFor(pidDir, port), []byte(pid), 0o600); err != nil { os.Exit(1) } } - if childPort != "" { - child := exec.Command(os.Args[0]) - child.Env = append(os.Environ(), envHelperPort+"="+childPort, envHelperChild+"=") - if err := child.Start(); err != nil { - os.Exit(1) - } + spawnHelper(childPort) } for { - conn, err := ln.Accept() + conn, err := sock.Accept() if err != nil { return } @@ -94,18 +87,41 @@ func runHelper(port, childPort, pidDir string, ignoreTerm bool) { } } +// spawnHelper starts one more helper on port, as this helper's child. +func spawnHelper(port string) { + child := exec.Command(os.Args[0]) + child.Env = append(os.Environ(), envHelperPort+"="+port, envHelperChild+"=") + if err := child.Start(); err != nil { + os.Exit(1) + } +} + // pidFileFor is the path a helper serving port reports itself to. func pidFileFor(dir, port string) string { return filepath.Join(dir, "helper-"+port+".pid") } // freePort asks the kernel for a port nothing is using and hands it back. func freePort(t *testing.T) int { t.Helper() - ln, err := net.Listen("tcp", "127.0.0.1:0") + sock, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { t.Fatalf("reserving a port: %v", err) } - defer ln.Close() - return ln.Addr().(*net.TCPAddr).Port + defer sock.Close() + addr, ok := sock.Addr().(*net.TCPAddr) + if !ok { + t.Fatalf("reserved %T, want a TCP address", sock.Addr()) + } + return addr.Port +} + +// mustExecutable is this test binary's path, which is also the helper program. +func mustExecutable(t *testing.T) string { + t.Helper() + exe, err := os.Executable() + if err != nil { + t.Fatalf("locating the test binary: %v", err) + } + return exe } // portAnswers is the test's own question — "is something accepting here?" — and @@ -116,13 +132,15 @@ func portAnswers(port int) bool { return probePort(port, "127.0.0.1") == portOpe func waitForPort(t *testing.T, port int, want bool) { t.Helper() deadline := time.Now().Add(10 * time.Second) - for time.Now().Before(deadline) { + for { if portAnswers(port) == want { return } + if time.Now().After(deadline) { + t.Fatalf("port %d: still open=%v after 10s, wanted open=%v", port, !want, want) + } time.Sleep(20 * time.Millisecond) } - t.Fatalf("port %d: still open=%v after 10s, wanted open=%v", port, !want, want) } // readPID waits for a helper to report the pid it runs as. @@ -153,10 +171,7 @@ type tree struct { // is a child of a shell, and waits until both listeners answer. func startTree(t *testing.T) tree { t.Helper() - exe, err := os.Executable() - if err != nil { - t.Fatalf("locating the test binary: %v", err) - } + exe := mustExecutable(t) dir := t.TempDir() parentPort, childPort := freePort(t), freePort(t) got := tree{parentPort: parentPort, childPort: childPort} @@ -219,13 +234,15 @@ func stopOwnedTree(cmd *exec.Cmd) { // that cannot see the socket table does not turn these cases into skips. func snapshotFor(t *testing.T, port, knownPID int) []ports.ListeningPort { t.Helper() - if found, err := ports.Scan(); err == nil { - docker.EnrichPorts(found) - ports.Enrich(found) - for i := range found { - if found[i].Port == port { - return found - } + found, err := ports.Scan() + if err != nil { + return []ports.ListeningPort{snapshotForPID(knownPID, port)} + } + docker.EnrichPorts(found) + ports.Enrich(found) + for _, row := range found { + if row.Port == port { + return found } } return []ports.ListeningPort{snapshotForPID(knownPID, port)} @@ -242,28 +259,34 @@ func TestKillPortsStopsARealProcessTree(t *testing.T) { Options{Tree: true, Grace: 2 * time.Second, Ports: snapshotFor(t, tree.parentPort, tree.parentPID)}, ) - if len(results) == 0 { - t.Fatal("no result rows") - } - for _, r := range results { - if !r.OK { - t.Errorf("row %+v failed: %s", r, r.Error) - } - } + requireAllOK(t, results) // The listener and the port it spawned are both gone. waitForPort(t, tree.parentPort, false) waitForPort(t, tree.childPort, false) // And the shell that was waiting on them has nothing left to wait for. - done := make(chan struct{}) - go func() { _ = tree.shell.Wait(); close(done) }() + exited := make(chan struct{}) + go func() { _ = tree.shell.Wait(); close(exited) }() select { - case <-done: + case <-exited: case <-time.After(10 * time.Second): t.Fatal("the shell is still running after its tree was killed") } } +// requireAllOK fails the test when any row reports a stop that did not happen. +func requireAllOK(t *testing.T, results []Result) { + t.Helper() + if len(results) == 0 { + t.Fatal("no result rows") + } + for i, r := range results { + if !r.OK { + t.Errorf("row %d (%+v) failed: %s", i, r, r.Error) + } + } +} + func TestKillPortsDryRunLeavesARealTreeRunning(t *testing.T) { if testing.Short() { t.Skip("spawns real processes") @@ -286,30 +309,17 @@ func TestKillPortsDryRunLeavesARealTreeRunning(t *testing.T) { root := results[len(results)-1].PID below := scanProcessTable().Descendants(root) for _, r := range results[:len(results)-1] { - if !contains(below, r.PID) { + if !slices.Contains(below, r.PID) { t.Errorf("row %+v is not below the last row (PID %d)", r, root) } } } -func contains(haystack []int, needle int) bool { - for _, v := range haystack { - if v == needle { - return true - } - } - return false -} - func TestKillPortsEscalatesOnAProcessThatIgnoresSIGTERM(t *testing.T) { if testing.Short() { t.Skip("spawns real processes") } - port := freePort(t) - exe, err := os.Executable() - if err != nil { - t.Fatal(err) - } + port, exe := freePort(t), mustExecutable(t) pidDir := t.TempDir() wedged := exec.Command(exe) wedged.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} @@ -330,14 +340,10 @@ func TestKillPortsEscalatesOnAProcessThatIgnoresSIGTERM(t *testing.T) { // The listener ignores SIGTERM, so only the escalation to SIGKILL frees // the port. A short grace keeps the test quick. snapshot := []ports.ListeningPort{snapshotForPID(readPID(t, pidFileFor(pidDir, strconv.Itoa(port))), port)} - results := KillPorts(context.Background(), []Target{{Port: port}}, - Options{Grace: 500 * time.Millisecond, Ports: snapshot}) + results := KillPorts(context.Background(), []Target{{Port: port}}, Options{Grace: 500 * time.Millisecond, Ports: snapshot}) - if len(results) != 1 { - t.Fatalf("results = %+v", results) - } - if results[0].Method != state.MethodSIGKILL { - t.Fatalf("method = %q, want sigkill after the grace period", results[0].Method) + if len(results) != 1 || results[0].Method != state.MethodSIGKILL { + t.Fatalf("results = %+v, want one sigkill row after the grace period", results) } waitForPort(t, port, false) } diff --git a/engine/internal/killer/killer_test.go b/engine/internal/killer/killer_test.go index 8f2211fb..145e6ec5 100644 --- a/engine/internal/killer/killer_test.go +++ b/engine/internal/killer/killer_test.go @@ -6,6 +6,7 @@ import ( "net" "os" "reflect" + "slices" "testing" "time" @@ -15,20 +16,21 @@ import ( // ---------------------------------------------------------------- harness --- -// signalCall is one delivery the fake machine recorded. +// fakeClock moves only when the code under test waits, so an escalation case +// costs no real time and cannot flake on a loaded machine. +type fakeClock struct { + now time.Time + slept time.Duration +} + +// signalCall is one delivery this fake host accepted: which pid, whether it was +// the forced signal, and whether the group adapter carried it. type signalCall struct { pid int force bool group bool } -// fakeClock advances only when the code under test sleeps, so an escalation -// test costs no wall-clock time and cannot flake on a slow machine. -type fakeClock struct { - now time.Time - slept time.Duration -} - func (c *fakeClock) Now() time.Time { return c.now } // Wait advances the fake clock instead of sleeping, and answers the same @@ -177,15 +179,9 @@ func (m *machine) signalledPIDs() []int { return out } -// listener is a scanned port row with just the fields the killer reads. +// listener is a scanned port row carrying only what the killer reads. func listener(port, pid int, opts ...func(*ports.ListeningPort)) ports.ListeningPort { - lp := ports.ListeningPort{ - Port: port, - PID: pid, - BindAddress: "127.0.0.1", - Process: "node", - Type: ports.PortTypeUser, - } + lp := ports.ListeningPort{Port: port, PID: pid, BindAddress: "127.0.0.1", Process: "node", Type: ports.PortTypeUser} for _, o := range opts { o(&lp) } @@ -193,17 +189,35 @@ func listener(port, pid int, opts ...func(*ports.ListeningPort)) ports.Listening } func methods(results []Result) []state.KillMethod { - out := make([]state.KillMethod, len(results)) - for i, r := range results { - out[i] = r.Method + out := make([]state.KillMethod, 0, len(results)) + for _, r := range results { + out = append(out, r.Method) } return out } func pidsOf(results []Result) []int { - out := make([]int, len(results)) - for i, r := range results { - out[i] = r.PID + out := make([]int, 0, len(results)) + for _, r := range results { + out = append(out, r.PID) + } + return out +} + +func namesOf(results []Result) []string { + out := make([]string, 0, len(results)) + for _, r := range results { + out = append(out, r.Name) + } + return out +} + +func (m *machine) forcedPIDs() []int { + var out []int + for _, c := range m.signals { + if c.force { + out = append(out, c.pid) + } } return out } @@ -217,15 +231,13 @@ func TestDryRunPlansTheTreeAndTouchesNothing(t *testing.T) { results := m.stop([]ports.ListeningPort{listener(3000, 200)}, []Target{{Port: 3000}}, Options{Tree: true, DryRun: true}) - if got, want := pidsOf(results), []int{400, 401, 300, 200}; !reflect.DeepEqual(got, want) { + if got, want := pidsOf(results), []int{400, 401, 300, 200}; !slices.Equal(got, want) { t.Fatalf("planned pids = %v, want %v (children before parents)", got, want) } for _, r := range results { - if r.Method != state.MethodSIGTERM || !r.OK { - t.Errorf("row %+v: want a successful sigterm plan", r) - } - if r.Port != 3000 || r.BindAddress != "127.0.0.1" { - t.Errorf("row %+v: every row carries the target port", r) + planned := r.Method == state.MethodSIGTERM && r.OK + if !planned || r.Port != 3000 || r.BindAddress != "127.0.0.1" { + t.Errorf("row %+v: want a successful sigterm plan for port 3000 on loopback", r) } } if len(m.signals) != 0 || len(m.stopped) != 0 { @@ -242,12 +254,7 @@ func TestDryRunNamesEveryProcessInTheTree(t *testing.T) { results := m.stop([]ports.ListeningPort{listener(3000, 200)}, []Target{{Port: 3000}}, Options{Tree: true, DryRun: true}) - want := []string{"esbuild", "esbuild", "node", "node"} - got := make([]string, len(results)) - for i, r := range results { - got[i] = r.Name - } - if !reflect.DeepEqual(got, want) { + if got, want := namesOf(results), []string{"esbuild", "esbuild", "node", "node"}; !slices.Equal(got, want) { t.Fatalf("names = %v, want %v", got, want) } } @@ -399,29 +406,20 @@ func TestEscalationOnlyHardKillsWhatIsStillAlive(t *testing.T) { results := m.stop([]ports.ListeningPort{listener(3000, 200)}, []Target{{Port: 3000}}, Options{Tree: true, Grace: time.Second}) - var hard []int - for _, c := range m.signals { - if c.force { - hard = append(hard, c.pid) - } - } - if !reflect.DeepEqual(hard, []int{200}) { + if hard := m.forcedPIDs(); !slices.Equal(hard, []int{200}) { t.Fatalf("SIGKILLed %v, want only the process still alive", hard) } - if got := methods(results); !reflect.DeepEqual(got, []state.KillMethod{ - state.MethodSIGTERM, state.MethodSIGTERM, state.MethodSIGTERM, state.MethodSIGKILL, - }) { - t.Fatalf("methods = %v", got) + wantMethods := []state.KillMethod{state.MethodSIGTERM, state.MethodSIGTERM, state.MethodSIGTERM, state.MethodSIGKILL} + if got := methods(results); !slices.Equal(got, wantMethods) { + t.Fatalf("methods = %v, want %v", got, wantMethods) } } func TestDockerContainersAreStoppedNotSignalled(t *testing.T) { m := newMachine(npmTree()) - snapshot := []ports.ListeningPort{listener(5432, 900, func(lp *ports.ListeningPort) { - lp.Type = ports.PortTypeDocker - lp.DockerContainer = "pg" - lp.DockerComposeService = "db" - })} + container := listener(5432, 900) + container.Type, container.DockerContainer, container.DockerComposeService = ports.PortTypeDocker, "pg", "db" + snapshot := []ports.ListeningPort{container} results := m.stop(snapshot, []Target{{Port: 5432}}, Options{Tree: true, Force: true}) @@ -439,11 +437,9 @@ func TestDockerContainersAreStoppedNotSignalled(t *testing.T) { func TestRunAttributedListenerKillsTheWholeRun(t *testing.T) { m := newMachine(npmTree()) // vite listens, but option-berth started the shell at pid 100. - snapshot := []ports.ListeningPort{listener(3000, 300, func(lp *ports.ListeningPort) { - lp.RunRootPID = 100 - lp.RunID = "abc123" - lp.Tag = "web" - })} + vite := listener(3000, 300) + vite.RunRootPID, vite.RunID, vite.Tag = 100, "abc123", "web" + snapshot := []ports.ListeningPort{vite} // No --tree: run attribution implies the whole tree anyway. m.stop(snapshot, []Target{{Port: 3000}}, Options{}) @@ -531,11 +527,8 @@ func TestUnknownPortIsANotFoundRow(t *testing.T) { results := m.stop(nil, []Target{{Port: 9999}}, Options{}) - if len(results) != 1 || results[0].OK || results[0].Port != 9999 { - t.Fatalf("results = %+v", results) - } - if results[0].Error == "" { - t.Fatal("a failed row must carry its error") + if len(results) != 1 || results[0].OK || results[0].Port != 9999 || results[0].Error == "" { + t.Fatalf("results = %+v, want one not-found row carrying its error", results) } } @@ -622,15 +615,12 @@ func TestCancelledContextStopsBeforeEscalation(t *testing.T) { } func TestOptionDefaults(t *testing.T) { - var o Options - if o.grace() != DefaultGrace { - t.Errorf("grace = %v, want %v", o.grace(), DefaultGrace) - } - if !o.escalating() { - t.Error("the zero Options must escalate") + zero := Options{} + if got := zero.grace(); got != DefaultGrace { + t.Errorf("grace = %v, want %v", got, DefaultGrace) } - if (Options{Force: true}).escalating() { - t.Error("--force is already the strongest signal; nothing to escalate to") + if !zero.escalating() || (Options{Force: true}).escalating() { + t.Error("zero options escalate; --force has nothing stronger to escalate to") } } From 877b57b5f4d40945229cba45470ceae32473dda2 Mon Sep 17 00:00:00 2001 From: zayJu Date: Thu, 12 Nov 2026 11:18:06 +0800 Subject: [PATCH 3/3] =?UTF-8?q?docs(engine):=20=E8=AE=B0=E5=BD=95=20killer?= =?UTF-8?q?=20=E9=87=8D=E5=86=99=E5=90=8E=E7=9A=84=E4=B8=8A=E6=B8=B8?= =?UTF-8?q?=E5=A4=8D=E5=88=B6=E9=87=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 对照 26dbcae:总量 8,933 → 8,689 行 / 636 段、131 个文件(生产 3,478、 测试 5,211),internal/killer 整包归零。 Co-Authored-By: Claude Code --- engine/UPSTREAM.md | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/engine/UPSTREAM.md b/engine/UPSTREAM.md index a56ffbd2..14aae37c 100644 --- a/engine/UPSTREAM.md +++ b/engine/UPSTREAM.md @@ -31,10 +31,10 @@ **9,621**(对照 `f4c50506`)、**9,659**(对照 `be48afbe`),生产 3,852、 测试 5,758。 -2026-11-12 的 ports 重写在此基础上继续下降:对照 `26dbcae` 为 **8,933** 行 / 664 段, -分布在 135 个文件(生产 3,564、测试 5,369)。 +2026-11-12 的 ports 与 killer 重写在此基础上继续下降:对照 `26dbcae` 为 **8,689** +行 / 636 段,分布在 131 个文件(生产 3,478、测试 5,211)。 -按包(对照 `26dbcae`,ports 重写后): +按包(对照 `26dbcae`,ports 与 killer 重写后): | 包 | 生产 | 测试 | 合计 | | --- | ---: | ---: | ---: | @@ -43,12 +43,13 @@ | `internal/groups` | 336 | 506 | 842 | | `internal/scanner` | 220 | 540 | 760 | | `internal/ports` | 0 | 0 | 0 | -| `internal/killer` | 86 | 158 | 244 | +| `internal/killer` | 0 | 0 | 0 | | `internal/store` | 12 | 5 | 17 | -`internal/claims`、`internal/display`、`internal/testenv`、`internal/ports` 与 -`internal/store/claims*.go` 已经归零;`internal/docker` 只剩结构体字段与表驱动 -用例数据。`internal/store` 已整包重写:生产代码从 1,076 行降到 199 行。 +`internal/claims`、`internal/display`、`internal/testenv`、`internal/ports`、 +`internal/killer` 与 `internal/store/claims*.go` 已经归零;`internal/docker` 只剩 +结构体字段与表驱动用例数据。`internal/store` 已整包重写:生产代码从 1,076 行降到 +199 行。 ```sh git clone https://github.com/raskrebs/sonar /tmp/sonar