TestRestartWorkersForceKillsStuckThread verifies the drain path does not hang when a worker is stuck in a blocking PHP call (sleep, etc.). macOS has no realtime signals so we can't unblock sleep() there; skip.
(t *testing.T)
| 16 | // not hang when a worker is stuck in a blocking PHP call (sleep, etc.). |
| 17 | // macOS has no realtime signals so we can't unblock sleep() there; skip. |
| 18 | func TestRestartWorkersForceKillsStuckThread(t *testing.T) { |
| 19 | if runtime.GOOS != "linux" && runtime.GOOS != "freebsd" && runtime.GOOS != "windows" { |
| 20 | t.Skipf("force-kill cannot interrupt blocking syscalls on %s", runtime.GOOS) |
| 21 | } |
| 22 | |
| 23 | prev := drainGracePeriod |
| 24 | drainGracePeriod = 500 * time.Millisecond |
| 25 | t.Cleanup(func() { drainGracePeriod = prev }) |
| 26 | |
| 27 | cwd, _ := os.Getwd() |
| 28 | testDataDir := cwd + "/testdata/" |
| 29 | |
| 30 | require.NoError(t, Init( |
| 31 | WithWorkers("sleep-worker", testDataDir+"worker-sleep.php", 1), |
| 32 | WithNumThreads(2), |
| 33 | )) |
| 34 | t.Cleanup(Shutdown) |
| 35 | |
| 36 | // Marker file the worker touches right before sleep(); per-run path |
| 37 | // so a stale file from a prior test can't fool the poll below. |
| 38 | markerFile := filepath.Join(t.TempDir(), "sleep-worker-in-sleep") |
| 39 | |
| 40 | // Worker handles the request, then sleep(60). Recorder lets us |
| 41 | // assert post-sleep code never runs (would indicate the VM interrupt |
| 42 | // didn't fire and only drainChan got picked up). |
| 43 | recorder := httptest.NewRecorder() |
| 44 | served := make(chan struct{}) |
| 45 | go func() { |
| 46 | defer close(served) |
| 47 | req := httptest.NewRequest("GET", "http://example.com/worker-sleep.php", nil) |
| 48 | req.Header.Set("Sleep-Marker", markerFile) |
| 49 | fr, err := NewRequestWithContext(req, WithRequestDocumentRoot(testDataDir, false)) |
| 50 | if err != nil { |
| 51 | return |
| 52 | } |
| 53 | _ = ServeHTTP(recorder, fr) |
| 54 | }() |
| 55 | |
| 56 | // Confirm the worker is parked in sleep() before triggering the |
| 57 | // restart, so we exercise the force-kill path and not drainChan. |
| 58 | require.Eventually(t, func() bool { |
| 59 | _, err := os.Stat(markerFile) |
| 60 | return err == nil |
| 61 | }, 5*time.Second, 10*time.Millisecond, "worker never entered sleep()") |
| 62 | |
| 63 | start := time.Now() |
| 64 | RestartWorkers() |
| 65 | elapsed := time.Since(start) |
| 66 | |
| 67 | // Test grace period (500ms) + 3s slack for signal dispatch, VM tick, restart loop. |
| 68 | const budget = 4 * time.Second |
| 69 | assert.Less(t, elapsed, budget, "drain must force-kill the stuck thread within the grace period") |
| 70 | |
| 71 | select { |
| 72 | case <-served: |
| 73 | case <-time.After(2 * time.Second): |
| 74 | t.Fatal("server request goroutine did not complete after drain") |
| 75 | } |
nothing calls this directly
no test coverage detected