fix worker restart port cleanup on Windows#2980
Conversation
Greptile SummaryThis PR fixes a Windows-specific race condition where a restarting worker leaves a ghost TCP socket on its port, blocking the successor from binding. It adds PowerShell/netstat-based port-owner detection, a
Confidence Score: 4/5Safe to merge; the new Windows cleanup path is gated by The core logic — PowerShell/netstat detection, process identity heuristic, and taskkill force-eviction — is sound and well-guarded. The issues found are a duplicate server-close implementation across two files and one exported function with no callers, neither of which affects runtime correctness.
Important Files Changed
Flowchart%%{init: {'theme': 'neutral'}}%%
flowchart TD
A[CLI: worker restart] --> B[httpShutdown port reason=restart]
B --> C{Shutdown accepted?}
C -- Yes --> D[verifyRestartedWorker primary handoff path]
D -- ok --> E[exit 0]
D -- timeout --> F[Fallback: forceKillStaleWorkerPortOwners]
C -- No --> F
F --> G{Killed stale owner?}
G -- Yes --> H[waitForPortFree]
G -- No --> I{handoffSawLiveWorker?}
H --> J[spawnDaemon successor]
I -- No --> K[waitForPortFree 15s]
I -- Yes --> L[Skip wait - port held by live worker]
K --> J
L --> M[verifyRestartedWorker]
J --> M
M -- ok --> E
M -- fail --> N[exit 1]
subgraph GracefulShutdown_closeHttpServer
O[server.close] --> P[50ms delay]
P --> Q[closeIdleConnections + closeAllConnections]
Q --> R{Race: closed vs 3s/1s timeout}
R -- closed --> S{Windows?}
R -- timeout --> T[closeAllConnections again]
S -- Yes --> U[500ms OS socket reclaim wait]
S -- No --> V[Done]
T --> V
U --> V
end
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
flowchart TD
A[CLI: worker restart] --> B[httpShutdown port reason=restart]
B --> C{Shutdown accepted?}
C -- Yes --> D[verifyRestartedWorker primary handoff path]
D -- ok --> E[exit 0]
D -- timeout --> F[Fallback: forceKillStaleWorkerPortOwners]
C -- No --> F
F --> G{Killed stale owner?}
G -- Yes --> H[waitForPortFree]
G -- No --> I{handoffSawLiveWorker?}
H --> J[spawnDaemon successor]
I -- No --> K[waitForPortFree 15s]
I -- Yes --> L[Skip wait - port held by live worker]
K --> J
L --> M[verifyRestartedWorker]
J --> M
M -- ok --> E
M -- fail --> N[exit 1]
subgraph GracefulShutdown_closeHttpServer
O[server.close] --> P[50ms delay]
P --> Q[closeIdleConnections + closeAllConnections]
Q --> R{Race: closed vs 3s/1s timeout}
R -- closed --> S{Windows?}
R -- timeout --> T[closeAllConnections again]
S -- Yes --> U[500ms OS socket reclaim wait]
S -- No --> V[Done]
T --> V
U --> V
end
|
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: bc2ef6c651
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| if (!restartFreed && !handoffSawLiveWorker) { | ||
| const killedStaleOwner = await forceKillStaleWorkerPortOwners(port, oldPid); |
There was a problem hiding this comment.
Preserve unverified restart successors
On Windows restart fallback, this force-kill runs before waiting for the port to free, and forceKillStaleWorkerPortOwners will also kill any claude-mem-looking owner whose PID is not oldPid by command-line match. If the old worker's handoff or another launcher has already spawned a successor that has bound the port but has not answered /api/health yet, this kills the legitimate replacement instead of waiting/verifying it, turning a slow restart into a failed one. Consider waiting first or only force-killing the captured oldPid in this path.
Useful? React with 👍 / 👎.
| export async function isWindowsPortHeldByMissingProcess(port: number): Promise<boolean> { | ||
| if (process.platform !== 'win32') return false; | ||
|
|
||
| const ownerPids = await getWindowsPortOwnerPids(port); | ||
| if (ownerPids.length === 0) return false; | ||
|
|
||
| for (const pid of ownerPids) { | ||
| if (pid === process.pid) return false; | ||
| const info = await getWindowsProcessInfo(pid); | ||
| if (info !== null) return false; | ||
| } | ||
|
|
||
| return true; | ||
| } |
There was a problem hiding this comment.
Exported but unreachable function
isWindowsPortHeldByMissingProcess is exported from HealthMonitor.ts but has no callers anywhere in the codebase (confirmed via grep). It will never be invoked, so its logic — which returns false at the first PID that is either the current process or is still alive, without evaluating remaining PIDs — is never exercised. If this function is intended as a future entry point, leaving it unused now risks it being wired up against the wrong semantics: on a multi-PID result, the presence of any one live PID causes it to say "not held by a missing process", even if another PID in the list is a ghost. If it isn't needed, removing it would reduce surface area.
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/services/infrastructure/HealthMonitor.ts
Line: 141-154
Comment:
**Exported but unreachable function**
`isWindowsPortHeldByMissingProcess` is exported from `HealthMonitor.ts` but has no callers anywhere in the codebase (confirmed via grep). It will never be invoked, so its logic — which returns `false` at the first PID that is either the current process or is still alive, without evaluating remaining PIDs — is never exercised. If this function is intended as a future entry point, leaving it unused now risks it being wired up against the wrong semantics: on a multi-PID result, the presence of any one live PID causes it to say "not held by a missing process", even if another PID in the list is a ghost. If it isn't needed, removing it would reduce surface area.
How can I resolve this? If you propose a fix, please make it concise.Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
# Conflicts: # plugin/scripts/server-beta-service.cjs
No description provided.