mirror of
https://github.com/alibaba/open-code-review.git
synced 2026-09-14 19:59:52 +08:00
d64a4c560a
* fix(review): exit immediately on the second signal (#1141) review_cmd.go registered SIGINT/SIGTERM via signal.NotifyContext. Its watcher goroutine receives the first signal, cancels the context and exits -- without unregistering the channel: stop() only runs in the caller's defer, after the whole graceful shutdown has finished. For that entire window (report flush, retry-report freeze, session persist, MCP client close) every further signal lands in the stdlib's unread size-one buffer and is dropped while the default kill behavior stays suppressed, so a second Ctrl+C does nothing however long the cleanup takes. Replace NotifyContext with an owned signal channel: the first signal cancels the context exactly as before, and the watcher now stays alive across the whole shutdown window; a second signal force-exits the process at once with status 1, skipping the remaining cleanup -- the user has explicitly abandoned the graceful path. A signal queued while stop() was closing done must not turn an already-successful run into exit 1, so the watcher drops latecomers once shutdown completion won the race. The buffer holds two entries: os/signal sends non-blockingly, so a size-one buffer could drop the second of two back-to-back signals before the watcher consumes the first. stop() is idempotent, unregisters the channel, releases the watcher and cancels the context, mirroring NotifyContext's stop semantics. Exit status stays plain 1 to match the current effective behavior; switching to the 128+sig convention (130 SIGINT / 143 SIGTERM) deserves its own discussion and is raised in the PR description. scan_cmd.go is untouched: it registers no signal handling today, and keeping it that way until #996 lands preserves symmetry between the two commands. Tests: in-process unit tests cover first-signal cancellation (SIGINT and SIGTERM), the second-signal forced exit through a stubbed hook, and watcher release on stop-before-signal and parent cancellation. Two subprocess integration tests re-execute the test binary so the real os.Exit path is exercised: a second SIGINT during a simulated 30s graceful shutdown ends the process well under 100ms with status 1, and a single SIGINT still completes the graceful path with the partial report persisted on disk and status 0. * ci: retrigger checks
101 lines
3.4 KiB
Go
101 lines
3.4 KiB
Go
// SPDX-License-Identifier: Apache-2.0
|
|
// Copyright 2026 alibaba/open-code-review Contributors
|
|
|
|
package main
|
|
|
|
import (
|
|
"context"
|
|
"os"
|
|
"os/signal"
|
|
"sync"
|
|
"syscall"
|
|
)
|
|
|
|
// forceExit terminates the process immediately when a second signal arrives.
|
|
// It is a variable so tests can intercept the forced-exit path without
|
|
// killing the test binary. No diagnostic is printed: an asynchronous write
|
|
// would almost never complete before os.Exit kills the process, and a
|
|
// synchronous write to a full stderr pipe would block the exit entirely.
|
|
var forceExit = func(sig os.Signal) {
|
|
os.Exit(1)
|
|
}
|
|
|
|
// interruptContextWithForcedExit returns a child of parent that is canceled
|
|
// by the first os.Interrupt or syscall.SIGTERM, together with a stop function
|
|
// that restores default signal handling.
|
|
//
|
|
// The first signal cancels the context so executeReviewContext's defer chain
|
|
// can run the graceful shutdown: flush the report, freeze the retry report,
|
|
// persist the session manifest, close MCP clients. signal.NotifyContext
|
|
// cannot serve this command's second requirement: its watcher goroutine
|
|
// exits right after the first signal without unregistering, so for the whole
|
|
// graceful-shutdown window every further signal lands in its unread buffered
|
|
// channel and is dropped while the default kill behavior stays suppressed —
|
|
// a second Ctrl+C does nothing. Here the watcher stays alive across the
|
|
// whole shutdown window and a second signal force-exits the process at
|
|
// once: the user has explicitly abandoned the graceful path, however long
|
|
// the remaining cleanup would take.
|
|
//
|
|
// stop is idempotent. It unregisters the signal channel, releases the
|
|
// watcher goroutine and cancels the context, mirroring the stop returned by
|
|
// signal.NotifyContext.
|
|
func interruptContextWithForcedExit(parent context.Context) (context.Context, context.CancelFunc) {
|
|
ctx, cancel := context.WithCancel(parent)
|
|
// Buffer 2: os/signal sends non-blockingly, so with a size-one buffer two
|
|
// back-to-back signals could drop the second before the watcher consumes
|
|
// the first — the very failure mode this function exists to eliminate.
|
|
ch := make(chan os.Signal, 2)
|
|
signal.Notify(ch, os.Interrupt, syscall.SIGTERM)
|
|
|
|
done := make(chan struct{})
|
|
var once sync.Once
|
|
// mu + stopped close the race between stop() and the watcher's
|
|
// late-signal check: without it, a signal already in ch's buffer can be
|
|
// received, pass a non-blocking done-probe, and force-exit a run whose
|
|
// shutdown already completed — all in the window between signal.Stop
|
|
// and close(done). Setting stopped and closing done under the same
|
|
// mutex the watcher checks makes the decision atomic.
|
|
var mu sync.Mutex
|
|
stopped := false
|
|
stop := func() {
|
|
once.Do(func() {
|
|
mu.Lock()
|
|
stopped = true
|
|
close(done)
|
|
mu.Unlock()
|
|
signal.Stop(ch)
|
|
cancel()
|
|
})
|
|
}
|
|
|
|
go func() {
|
|
defer stop()
|
|
select {
|
|
case <-ch:
|
|
// First signal: start the graceful shutdown.
|
|
cancel()
|
|
// Second signal: the user wants out now; skip the remaining
|
|
// cleanup. stop() (graceful shutdown finished first) releases
|
|
// the watcher instead.
|
|
select {
|
|
case sig := <-ch:
|
|
// A signal queued while stop() was running must not turn
|
|
// an already-successful run into exit 1.
|
|
mu.Lock()
|
|
if stopped {
|
|
mu.Unlock()
|
|
return
|
|
}
|
|
mu.Unlock()
|
|
forceExit(sig)
|
|
case <-done:
|
|
}
|
|
case <-ctx.Done():
|
|
// The parent context or the review ended before any signal.
|
|
case <-done:
|
|
}
|
|
}()
|
|
|
|
return ctx, stop
|
|
}
|