| index 8d771b423..96386e86e 100644 |
| --- a/internal/activator/activator_test.go |
| +++ b/internal/activator/activator_test.go |
| @@ -20,6 +20,7 @@ import ( |
| "net/http/httptest" |
| "net/http/httputil" |
| "os" |
| + "os/exec" |
| "path/filepath" |
| "slices" |
| "strings" |
| @@ -3610,3 +3611,128 @@ func TestExecBackendDoesNotNormalizeSquatterSocket(t *testing.T) { |
| t.Fatalf("squatter socket mode after Start = %o, want its own 600 — a socket our child did not bind is never ours to widen", fi.Mode().Perm()) |
| } |
| } |
| + |
| +// The control socket must OUTLIVE the drain. Stop used to close it before the |
| +// child was even signalled, which made the spec's §7.1 story — an app that |
| +// re-asserts its once-timers as it winds down, over the §7.2 verbs on |
| +// CARLOS_CONTROL_SOCKET — a connection-refused during exactly the window the |
| +// app was told to use it. |
| +// |
| +// The test drives the real childProc: a child that traps SIGTERM and stays up |
| +// until the test releases it, a real edge.ControlListener with a verb |
| +// mounted, and a request issued strictly between "SIGTERM delivered" and |
| +// "child exited". It also pins the other half — that the listener is still |
| +// closed, on this path, once the child is gone. |
| +func TestChildProcStopKeepsTheControlSocketUpUntilTheChildExits(t *testing.T) { |
| + requireUnixSockets(t) |
| + dir := t.TempDir() |
| + ready := filepath.Join(dir, "ready") // the child's SIGTERM trap is installed |
| + termed := filepath.Join(dir, "termed") // the child saw SIGTERM |
| + release := filepath.Join(dir, "release") // the test lets the child finish |
| + script := filepath.Join(dir, "app") |
| + // The ready marker is touched AFTER the trap is installed, and the test |
| + // waits for it before signalling: a SIGTERM that arrives in the shell's |
| + // first few instructions takes the default action and kills the child |
| + // outright, and there is no drain window to observe. |
| + body := "#!/bin/sh\n" + |
| + "trap \"touch '" + termed + "'\" TERM\n" + |
| + "touch '" + ready + "'\n" + |
| + "while [ ! -f '" + release + "' ]; do sleep 0.02; done\n" + |
| + "exit 0\n" |
| + if err := os.WriteFile(script, []byte(body), 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + ctlSock := tmpSock(t) |
| + ctl, err := edge.ListenControl(ctlSock, "drain.example.com", nil, func(mux *http.ServeMux) { |
| + mux.HandleFunc("GET /schedule", func(w http.ResponseWriter, r *http.Request) { |
| + w.WriteHeader(http.StatusOK) |
| + }) |
| + }) |
| + if err != nil { |
| + t.Fatalf("ListenControl: %v", err) |
| + } |
| + |
| + cmd := exec.Command(script) |
| + if err := cmd.Start(); err != nil { |
| + ctl.Close() |
| + t.Fatalf("start the fake child: %v", err) |
| + } |
| + p := &childProc{cmd: cmd, done: make(chan struct{}), ctl: ctl} |
| + go func() { |
| + p.err = cmd.Wait() |
| + close(p.done) |
| + }() |
| + |
| + waitForFile(t, ready, 10*time.Second) |
| + |
| + stopped := make(chan error, 1) |
| + go func() { |
| + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) |
| + defer cancel() |
| + stopped <- p.Stop(ctx) |
| + }() |
| + |
| + // Strictly inside the drain: SIGTERM has been delivered (the child's own |
| + // trap says so) and the child has not exited. |
| + waitForFile(t, termed, 10*time.Second) |
| + if p.Exited() { |
| + t.Fatal("the child exited before the drain window could be observed — the test proves nothing") |
| + } |
| + req, err := http.NewRequest("GET", "http://carlos/schedule", nil) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + resp, err := socketClient(ctlSock).Do(req) |
| + if err != nil { |
| + t.Fatalf("control request during the drain: %v — the socket must still answer while the child drains", err) |
| + } |
| + resp.Body.Close() |
| + if resp.StatusCode != http.StatusOK { |
| + t.Fatalf("control request during the drain = %d, want 200", resp.StatusCode) |
| + } |
| + |
| + if err := os.WriteFile(release, nil, 0o600); err != nil { |
| + t.Fatal(err) |
| + } |
| + select { |
| + case err := <-stopped: |
| + if err != nil { |
| + t.Fatalf("Stop: %v", err) |
| + } |
| + case <-time.After(30 * time.Second): |
| + t.Fatal("Stop never returned after the child was released") |
| + } |
| + // The close is only deferred, not dropped: a listener left bound to a |
| + // socket with no process behind it is the #129 stale-socket wake trap. |
| + if _, err := os.Stat(ctlSock); !os.IsNotExist(err) { |
| + t.Fatalf("control socket after Stop returned: stat err = %v, want it unlinked", err) |
| + } |
| +} |
| + |
| +// The already-exited path takes an early return, and the listener close has |
| +// to survive it: an instance whose child crashed on its own still leaves the |
| +// agent holding a bound socket nothing is behind. |
| +func TestChildProcStopClosesTheControlSocketWhenTheChildIsAlreadyGone(t *testing.T) { |
| + requireUnixSockets(t) |
| + ctlSock := tmpSock(t) |
| + ctl, err := edge.ListenControl(ctlSock, "gone.example.com", nil, nil) |
| + if err != nil { |
| + t.Fatalf("ListenControl: %v", err) |
| + } |
| + cmd := exec.Command("/bin/sh", "-c", "exit 0") |
| + if err := cmd.Start(); err != nil { |
| + ctl.Close() |
| + t.Fatal(err) |
| + } |
| + p := &childProc{cmd: cmd, done: make(chan struct{}), ctl: ctl} |
| + p.err = cmd.Wait() |
| + close(p.done) // the child is gone before Stop is ever called |
| + |
| + if err := p.Stop(context.Background()); err != nil { |
| + t.Fatalf("Stop on an already-exited child: %v", err) |
| + } |
| + if _, err := os.Stat(ctlSock); !os.IsNotExist(err) { |
| + t.Fatalf("control socket after Stop on an exited child: stat err = %v, want it unlinked", err) |
| + } |
| +} |