mirror of
https://github.com/zap-proto/zip.git
synced 2026-10-19 15:38:57 +00:00
fix(shutdown): wait out the children past an ended context
This commit is contained in:
@@ -56,7 +56,7 @@ Patch tags, one above the highest `v*` on GitHub. Push the commit to main, read
|
||||
- The call-plane body is ZAP, laid out from struct field order (`internal/zapenc`). Append fields at the end. Reordering, inserting or retyping a field changes the wire.
|
||||
- A pointer to a scalar, string or `[]byte` crosses as `list<T>` of at most one element: nil is the empty list, a pointer to zero is one zero element. A pointer to a struct is its message in a `bytes` slot, present when the slot is not null. `zapenc`, the `Layouts` emitter and the Rust projector (`TypeRef.Opt`) all follow this. A `*[N]byte` stays inline, so a pointer to an all-zero array reads back nil. Lists decode in one pass (`List.EachBytes`) and grow from the entries present, never the claimed count.
|
||||
- A plugin request holds its instance (`hold`, `instance.busy`) until the reply is written, a streamed body is closed (zap-proto/http closes it when the connection ends, v0.3.12+), or an upgrade's tunnel ends; an upgrade over ZAP is refused 501 because ZAP's server never runs a hijack, and a declined upgrade's reply is read as a reply, bounded by `declineWait`. `evict` raises `closing` before it reads `busy`; `claim` raises `busy` before it reads `closing`. Eviction never stops a busy instance and takes idle ones coldest first by when their last request ended, skipping one whose lock is held. A starter waits for room until its own deadline (`Plugin.Start` from when it arrived), then 503; the ceiling is exceeded only when no process may be stopped and no start is in flight. `lazy`/`idle`/`start` are copied off the spec at Load because the request path reads them unlocked.
|
||||
- Stopping a child is `terminate`: SIGTERM, then `Plugin.Grace` (default 20 s) for it to exit on its own, then SIGKILL. Every stop goes through it (Shutdown, Reload, Unload, eviction, a failed start). A plugin's shutdown hook only signals (`retire`); the outermost `Shutdown` waits for every child at once (`awaitChildren`), so N children cost the longest grace, not the sum. A composed app's shutdown runs as its parent's hook with `nestedShutdown` on the context and leaves the wait to the parent.
|
||||
- Stopping a child is `terminate`: SIGTERM, then `Plugin.Grace` (default 20 s) for it to exit on its own, then SIGKILL. Every stop goes through it (Shutdown, Reload, Unload, eviction, a failed start). A plugin's shutdown hook only signals (`retire`); the outermost `Shutdown` waits for every child at once (`awaitChildren`), so N children cost the longest grace, not the sum. That wait ignores ctx: the drain usually spends it, and a host that returned first would be torn down under its children. A composed app's shutdown runs as its parent's hook with `nestedShutdown` on the context and leaves the wait to the parent.
|
||||
- Doc prose is filed under `DocKey(pkg, method, path)`. zipdoc writes the package's import path. `pkgOf` reads the handler's package from its runtime name and maps `main` to the build info path, because a built binary names package main's functions `main.` while `go test` names them by import path.
|
||||
- MCP over HTTP: POST only (GET and DELETE answer 405), one message per request, no sessions, no batches, notifications 202. `negotiate` answers `initialize` in the requested revision when it is 2025-06-18, 2025-11-25 or 2026-07-28, else in 2026-07-28. The official TypeScript (1.30.0) and Python (2.2.0) clients refuse an answer in a revision they did not request and do not know.
|
||||
- `AdaptNetHTTP` carries an upgrade: a request with `Connection: Upgrade` runs the handler inside fasthttp's hijack, on a writer that is an `http.Hijacker`, so a net/http WebSocket server (coder/websocket, gorilla) writes its own 101 and owns the socket; a handler that declines answers an ordinary HTTP/1.1 reply. `wsx` stays the native way for a zip handler.
|
||||
|
||||
+7
-15
@@ -76,14 +76,16 @@ func (a *App) shutdown(ctx context.Context) error {
|
||||
}
|
||||
}
|
||||
// 4. Wait for every plugin child to exit. The hooks only signalled them, so
|
||||
// they drain together and each is killed at its own grace; the host
|
||||
// returns once the last is gone, or when ctx ends. A composed app's
|
||||
// they drain together, and each is killed at its own grace. That grace,
|
||||
// not ctx, bounds this wait: ctx has usually been spent on the drain, and
|
||||
// a host that returned first would be torn down under its children (a
|
||||
// container stops every process when its first one exits). A composed app's
|
||||
// shutdown runs as its parent's hook and leaves the wait to the
|
||||
// outermost one, which walks the whole composition: waiting at each level
|
||||
// would drain the children one after another.
|
||||
if !nested {
|
||||
if g, err := a.liveOrBuild(); err == nil {
|
||||
errs = append(errs, awaitChildren(ctx, g.hosts))
|
||||
awaitChildren(g.hosts)
|
||||
}
|
||||
}
|
||||
return errors.Join(errs...)
|
||||
@@ -94,26 +96,16 @@ func (a *App) shutdown(ctx context.Context) error {
|
||||
type nestedShutdown struct{}
|
||||
|
||||
// awaitChildren blocks until no plugin in hosts has a process left to stop.
|
||||
func awaitChildren(ctx context.Context, hosts []*App) error {
|
||||
var ps []*plugin
|
||||
func awaitChildren(hosts []*App) {
|
||||
for _, h := range hosts {
|
||||
h.plugMu.Lock()
|
||||
ps := make([]*plugin, 0, len(h.plugins))
|
||||
for _, p := range h.plugins {
|
||||
ps = append(ps, p)
|
||||
}
|
||||
h.plugMu.Unlock()
|
||||
}
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
for _, p := range ps {
|
||||
p.stopping.Wait()
|
||||
}
|
||||
close(done)
|
||||
}()
|
||||
select {
|
||||
case <-done:
|
||||
return nil
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package zip_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
@@ -94,3 +95,24 @@ func TestShutdown_ChildrenDrainTogether(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The wait for the children is bounded by their grace, not by the context a
|
||||
// shutdown was given: that context is usually spent on the drain, and a host
|
||||
// that returned before its children would be torn down under them.
|
||||
func TestShutdown_OutlivesAnEndedContext(t *testing.T) {
|
||||
bin := buildPlugin(t, "v1")
|
||||
mark := filepath.Join(t.TempDir(), "drained")
|
||||
|
||||
app := zip.New(zip.Config{AppName: "host", DisableStartupMessage: true})
|
||||
app.Use(must(zip.Load(zip.Plugin{Name: "demo", Bin: bin, Env: []string{
|
||||
"TESTPLUGIN_ON_TERM=" + mark, "TESTPLUGIN_TERM_DELAY=300ms",
|
||||
}}, "/v1/demo")))
|
||||
version(t, app)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
_ = app.ShutdownWithContext(ctx)
|
||||
if _, err := os.Stat(mark); err != nil {
|
||||
t.Fatalf("the host returned before its child finished: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user