fix(status): count all live worker threads for get_status cross-thread aggregation - #1952
fix(status): count all live worker threads for get_status cross-thread aggregation#1952kriszyp wants to merge 2 commits into
Conversation
…d aggregation CrossThreadStatusCollector.collect() sized expectedResponses from getWorkerCount(), which only reports the CALLING thread's own same-type pool -- and falls back to a hardcoded 1 when called from the main thread, since main isn't part of any worker pool. In a normal deployment get_status runs on main while N HTTP workers exist, so the collector capped expectedResponses at 1 and declared the collection complete after the first worker replied, silently dropping every other (and possibly disagreeing) worker's status. Verified live with threads.count=8: get_status's aggregated component status only ever reflected 1 of 8 workers before this fix. Use the already-tracked `workers` registry from manageThreads.js (populated on the thread that spawns children) instead, excluding job workers since broadcastWithAcknowledgement() never sends them the underlying ITC broadcast. Refs #1951 (the "invisible to get_status" half of that finding; the underlying cross-component load-order race that causes workers to diverge in the first place is a separate, larger feature gap tracked by #1931 and is not addressed here). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Code Review
This pull request improves the cross-thread status collection by using the authoritative workers registry (excluding job workers) to calculate expected responses, and adds a corresponding unit test. The review feedback highlights a potential 5-second stall during rolling restarts or in zero-worker configurations, and suggests filtering out shutting-down workers. Additionally, the feedback recommends avoiding timing flakiness in the test by using setImmediate instead of setTimeout, and properly capturing and restoring the shared workers array to prevent cross-test contamination.
| const nonJobWorkerCount = workers.filter((worker) => !worker.isJobWorker).length; | ||
| const expectedResponses = nonJobWorkerCount || getWorkerCount() || 1; |
There was a problem hiding this comment.
Issue: Potential 5-second stall during rolling restarts/reloads or in zero-worker configurations
There are two distinct scenarios where the current calculation of expectedResponses can cause get_status calls to stall for the full 5-second timeout:
- During rolling restarts/reloads: When a worker is being shut down,
worker.wasShutdownis set totrueinmanageThreads.js. However, the worker remains in theworkersarray until its'exit'event fires (which can take up to 10–30 seconds). Since these shutting-down workers are still in theworkersarray, they are included innonJobWorkerCountbut will not respond to theCOMPONENT_STATUS_REQUESTITC event. This causes the collector to wait for responses that will never arrive, stalling the status aggregation for the full 5-second timeout. - In zero-worker configurations: On the main thread in a zero-worker setup (or during early startup/late shutdown),
workersis empty andgetWorkerCount()returnsundefined. Due to the|| 1fallback,expectedResponsesis set to1. Since there are no workers to respond, the collector stalls for the full 5-second timeout.
Solution:
- Exclude workers that are shutting down by checking
!worker.wasShutdown. - Change the fallback from
|| 1to|| 0. SincegetWorkerCount()always returns a number>= 1on worker threads, the|| 1fallback is only reached on the main thread when there are genuinely 0 workers, where we should expect0responses and resolve immediately.
| const nonJobWorkerCount = workers.filter((worker) => !worker.isJobWorker).length; | |
| const expectedResponses = nonJobWorkerCount || getWorkerCount() || 1; | |
| const nonJobWorkerCount = workers.filter((worker) => !worker.isJobWorker && !worker.wasShutdown).length; | |
| const expectedResponses = nonJobWorkerCount || getWorkerCount() || 0; |
| onMessageByTypeStub.callsFake((eventType, handler) => { | ||
| setTimeout(() => { | ||
| handler({ | ||
| message: { | ||
| requestId: 1, | ||
| workerIndex: 1, | ||
| isMainThread: false, | ||
| statuses: [['poolComp', { status: 'healthy' }]], | ||
| }, | ||
| }); | ||
| handler({ | ||
| message: { | ||
| requestId: 1, | ||
| workerIndex: 2, | ||
| isMainThread: false, | ||
| statuses: [['poolComp', { status: 'healthy' }]], | ||
| }, | ||
| }); | ||
| // No third response -- the (excluded) job worker never gets asked. | ||
| }, 50); | ||
| }); |
There was a problem hiding this comment.
Improvement: Avoid fixed sleeps and timing flakiness in tests
Using setTimeout with a fixed 50ms delay to simulate asynchronous responses can introduce flakiness in slow or resource-constrained CI environments.
Replacing setTimeout with setImmediate defers the execution to the next turn of the event loop (guaranteeing that the collector has set up its response map) while executing virtually instantaneously. This makes the test faster and completely immune to timing-related flakiness.
onMessageByTypeStub.callsFake((eventType, handler) => {
setImmediate(() => {
handler({
message: {
requestId: 1,
workerIndex: 1,
isMainThread: false,
statuses: [['poolComp', { status: 'healthy' }]],
},
});
handler({
message: {
requestId: 1,
workerIndex: 2,
isMainThread: false,
statuses: [['poolComp', { status: 'healthy' }]],
},
});
});
});References
- Prefer using condition-waits (e.g., a helper like
waitForthat polls for a condition) instead of fixed sleeps or real-time delays (likesetTimeout) in tests to avoid flakiness caused by coarse clock resolutions or slow environments.
| const httpWorkerA = {}; | ||
| const httpWorkerB = {}; | ||
| const jobWorker = { isJobWorker: true }; | ||
| manageThreadsModule.workers.push(httpWorkerA, httpWorkerB, jobWorker); |
There was a problem hiding this comment.
Improvement: Capture original state of shared array to prevent cross-test contamination
To prevent side effects on other tests, capture the original state of the shared manageThreadsModule.workers array so it can be fully restored in the finally block.
| manageThreadsModule.workers.push(httpWorkerA, httpWorkerB, jobWorker); | |
| const originalWorkers = [...manageThreadsModule.workers]; | |
| manageThreadsModule.workers.push(httpWorkerA, httpWorkerB, jobWorker); |
| manageThreadsModule.workers.length = 0; // restore the shared array for other tests | ||
| getWorkerCountStub.restore(); |
There was a problem hiding this comment.
Improvement: Fully restore original state of shared array
Restore the original elements of the shared manageThreadsModule.workers array captured at the start of the test, rather than just clearing it.
manageThreadsModule.workers.length = 0;
manageThreadsModule.workers.push(...originalWorkers);
getWorkerCountStub.restore();|
Reviewed; no blockers found. |
Address get_status cross-thread review findings on PR #1952: - crossThread.ts sized expectedResponses from workers.filter(...) with a `|| getWorkerCount() || 1` fallback chain, which coerced a genuine zero (threads.count: 0, or a job-worker-only process) back to 1 -- the collector would then wait out the full 5s timeout for a response that never arrives. Replace it with getEligibleBroadcastRecipientCount(), a new manageThreads.js helper that mirrors broadcastWithAcknowledgement()'s own connectedPorts/isJobWorker filter. Since connectedPorts is a full mesh (every thread holds a direct port to every other live thread), this is exact from any calling thread -- main or worker -- and correctly returns 0. - The new regression test relied on a 50ms setTimeout / 500ms wall-clock deadline, which can flake on a loaded CI runner. Replace it with synchronous handler invocation plus a setImmediate sentinel: since microtasks always drain before any macrotask, asserting the sentinel hasn't fired proves collect() resolved via the response-handler path, deterministically, with no timing race. Add matching zero-responder and job-worker-only coverage. - The new test's `sinon.stub(manageThreadsModule, 'getWorkerCount')` was both a new-sinon-usage violation (AGENTS.md bans new sinon/rewire in tests) and unnecessary once expectedResponses no longer reads getWorkerCount() at all. Drop it, along with now-dead getWorkerCount stubs in five pre-existing tests in this file, updating them to populate the real (now-exported) connectedPorts array instead -- the same real-module-injection pattern already used for `workers`. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
|
The fix itself checks out —
Non-blocking:
Review by @heskew (posted via Claude). Generated by Claude Code |
What / why
CrossThreadStatusCollector.collect()(components/status/crossThread.ts) sizedexpectedResponses— how many worker-thread responses to wait for before considering aget_statuscross-thread aggregation complete — fromgetWorkerCount(). That function only reports the calling thread's own same-type pool size, and falls back to a hardcoded1when called from the main thread (since main isn't itself part of any worker pool).In a normal deployment,
get_statusis served from the main thread while N HTTP worker threads run. So the collector cappedexpectedResponsesat1and declared the collection complete after the first worker replied, silently discarding every other worker's status — including any that disagreed.Verified live (repro fixture booted with
threads.count: 8): before this fix,get_status's aggregatedcomponentStatusonly ever reflected 1 of 8 worker threads, regardless of how many actually responded or disagreed. This is the concrete mechanism behind the "invisible toget_status" half of #1951.Fix
Use the already-tracked
workersregistry fromserver/threads/manageThreads.js(populated on the thread that spawns children — normally main) to sizeexpectedResponses, excluding job workers:broadcastWithAcknowledgement()(which the underlying ITC broadcast goes through) explicitly skipsisJobWorkerports, so counting them would makeexpectedResponsesunreachable and stall everyget_statuscall to its full timeout whenever a job worker is running. Falls back to the oldgetWorkerCount()-based estimate whenworkersis empty (a genuinely zero-worker process, or — more commonly — this thread isn't the one that spawns children and has no visibility into siblings).What this does NOT fix
The underlying cross-component load-order race that causes some worker threads to diverge from others in the first place is unaddressed here — that requires the dependency-declaration mechanism #1931 asks for. This PR only closes the operational-visibility gap: once a divergence exists,
get_statusnow actually aggregates from every live worker thread instead of just one.Known residual limitation (not a regression)
If
collect()is invoked from a non-main thread in a deployment with multiple distinct worker-thread types (e.g. HTTP + job pools),workersis empty there too (only the spawning thread populates it), so it falls back to the pre-existinggetWorkerCount()-based same-type-pool estimate — still an undercount in that specific scenario, but no worse than before this fix. Flagged by the cross-model review below; left as a documented follow-up rather than a blocker since fixing it generally requires threading a real total-thread-count through IPC.Cross-model review
Ran the
cross-model-reviewskill (standard mode: Gemini viaagy, plus a direct Codexcodex execpass since thereviewer/codex-reviewerorchestration subagents aren't available in this headless environment — legs run directly and adjudicated inline).workersmight be a Map/Object — it's a plain array, refuted by reading the source), one already-accepted residual limitation (above), and a valid simplification (workers.length || getWorkerCount() || 1over the more verbose ternary — applied).workers.lengthas originally written counted job workers, butbroadcastWithAcknowledgement()(server/threads/manageThreads.js) explicitly skipsisJobWorkerports — so any job worker in the process would makeexpectedResponsespermanently unreachable, stalling everyget_statuscall to its 5s timeout. Fixed by filteringworkersto excludeisJobWorkerentries; added unit test coverage for exactly this scenario (2 HTTP workers + 1 job worker →expectedResponsesmust be 2, not 3).Test plan
npm run build(tsc) — cleannpx mocha "unitTests/components/status/**/*.js"— 136/136 passing (135 existing + 1 new, covering the job-worker-exclusion path)npm run lint:required— cleanthreads.count: 8, two components with an undeclared load-time dependency): confirmed pre-fix thatget_statusonly reflected 1 of 8 workers even while genuine per-worker request divergence (200/500mix) was occurring; confirmed post-fix that all 8 workers' status is now correctly aggregated (fast, ~15ms, no timeout stall)get_statusdivergence visibility) — the qa-scratch fixture used for verification isn't part of this diff; a proper integration test for the cross-component race itself is more naturally scoped to whatever PR eventually addresses [Core] No supported way for a component to declare it serves REST resources (forces boot-order hacks) #1931/Component load race fails per-worker (200/500 split-brain at threads>1) and is invisible to get_status #1951's root causeRefs #1951, #1931
🤖 Generated with Claude Code