mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-17 04:32:31 +03:00
Merge remote-tracking branch 'origin/release/v3.8.49' into feat/port-pr-2558-reorder-connections-by-availability
This commit is contained in:
37
.github/workflows/ci.yml
vendored
37
.github/workflows/ci.yml
vendored
@@ -135,6 +135,13 @@ jobs:
|
||||
# check:docs-sync is run by the docs-sync-strict job (via check:docs-all) and the
|
||||
# husky pre-commit hook; the standalone copy here was redundant (ROI dedup).
|
||||
- run: npm run typecheck:core
|
||||
# #7033: typecheck:core's curated file allowlist does not cover
|
||||
# src/app/(dashboard) TSX (and next.config.mjs sets ignoreBuildErrors:
|
||||
# true, so `next build` never type-checks it either) — orphaned
|
||||
# identifiers there (see #6625/#6909) were invisible to CI. This gate
|
||||
# runs tsc scoped to the dashboard tree against a frozen baseline of
|
||||
# pre-existing errors; only NEW errors fail it.
|
||||
- run: npm run check:dashboard-typecheck
|
||||
# typecheck:noimplicit:core dropped from this job (2026-07 optimize):
|
||||
# it was advisory (continue-on-error) and largely subsumed by the blocking
|
||||
# check:type-coverage ratchet in quality-gate. Local: npm run typecheck:noimplicit:core.
|
||||
@@ -751,11 +758,23 @@ jobs:
|
||||
- uses: ./.github/actions/npm-ci-retry
|
||||
# The second test runner (CLAUDE.md: "Both test runners must pass") — was never
|
||||
# wired into CI until the 2026-06-09 quality audit (Fase 6A.2).
|
||||
- run: npm run test:vitest
|
||||
# WS5.2/5.3 (v3.8.49 plan): JUnit output feeds Trunk Flaky Tests (advisory upload
|
||||
# below). node:test stays OUT of the first wave (fd1-sensitive reporter stream).
|
||||
- run: npm run test:vitest -- --reporter=default --reporter=junit --outputFile.junit=trunk-junit/vitest-mcp.xml
|
||||
# vitest:ui went back to 870/870 green in the v3.8.49 quality plan (WS6.1,
|
||||
# PR #7127 — 69 fails triaged: matchMedia polyfill, node:test→vitest migration,
|
||||
# CompareTab D22 cap). Promoted to BLOCKING per the plan's post-merge step.
|
||||
- run: npm run test:vitest:ui
|
||||
- run: npm run test:vitest:ui -- --reporter=default --reporter=junit --outputFile.junit=trunk-junit/vitest-ui.xml
|
||||
# Trunk Flaky Tests upload — advisory (never blocks), own-origin only (fork PRs
|
||||
# have no TRUNK_TOKEN). Pinned by SHA (tag v2.1.2).
|
||||
- name: Upload test results to Trunk (advisory)
|
||||
if: ${{ always() && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) }}
|
||||
continue-on-error: true
|
||||
uses: trunk-io/analytics-uploader@385f1ccdf345b4532dc4b6c665dd432b702b8e28 # v2.1.2
|
||||
with:
|
||||
junit-paths: trunk-junit/**/*.xml
|
||||
org-slug: omniroute
|
||||
token: ${{ secrets.TRUNK_TOKEN }}
|
||||
|
||||
# Node 24/26 compatibility matrices moved to .github/workflows/nightly-compat.yml
|
||||
# (plano mestre testes+CI, Eixo D2 — they cost ~28% of every heavy run to catch a
|
||||
@@ -1045,16 +1064,26 @@ jobs:
|
||||
- name: Run E2E tests (duration-balanced shard)
|
||||
env:
|
||||
SHARD: ${{ matrix.shard }}
|
||||
PLAYWRIGHT_JUNIT_OUTPUT_NAME: junit-e2e-results.xml
|
||||
run: |
|
||||
if FILES=$(node scripts/quality/balance-e2e-shards.mjs "$SHARD" 9); then
|
||||
if [ -z "$FILES" ]; then echo "[e2e-balance] shard $SHARD has no files"; exit 0; fi
|
||||
echo "[e2e-balance] shard $SHARD runs:"; echo "$FILES"
|
||||
# shellcheck disable=SC2086 — FILES is our own newline-separated path list
|
||||
npx playwright test $(echo "$FILES" | tr '\n' ' ')
|
||||
npx playwright test $(echo "$FILES" | tr '\n' ' ') --reporter=line,junit
|
||||
else
|
||||
echo "[e2e-balance] balancer unavailable — plain --shard fallback"
|
||||
npx playwright test tests/e2e/*.spec.ts --shard="$SHARD"/9
|
||||
npx playwright test tests/e2e/*.spec.ts --shard="$SHARD"/9 --reporter=line,junit
|
||||
fi
|
||||
# WS5.2/5.3: Trunk Flaky Tests upload — advisory, own-origin only, SHA-pinned.
|
||||
- name: Upload test results to Trunk (advisory)
|
||||
if: ${{ always() && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) }}
|
||||
continue-on-error: true
|
||||
uses: trunk-io/analytics-uploader@385f1ccdf345b4532dc4b6c665dd432b702b8e28 # v2.1.2
|
||||
with:
|
||||
junit-paths: junit-e2e-results.xml
|
||||
org-slug: omniroute
|
||||
token: ${{ secrets.TRUNK_TOKEN }}
|
||||
|
||||
test-integration:
|
||||
name: Integration Tests (${{ matrix.shard }}/2)
|
||||
|
||||
17
.github/workflows/quality.yml
vendored
17
.github/workflows/quality.yml
vendored
@@ -143,6 +143,10 @@ jobs:
|
||||
- run: npm run check:complexity-ratchets
|
||||
- name: Typecheck (core)
|
||||
run: npm run typecheck:core
|
||||
# #7033: dashboard-scoped typecheck gate — src/app/(dashboard) TSX is not
|
||||
# covered by typecheck:core's curated allowlist. See check-dashboard-typecheck.mjs.
|
||||
- name: Typecheck (dashboard)
|
||||
run: npm run check:dashboard-typecheck
|
||||
# WS4.2 (v3.8.49 plan): TypeScript 7 native-compiler SHADOW — advisory only.
|
||||
# TS7 went GA 2026-07-08 with 8-12x type-check speedups; its Compiler API only
|
||||
# arrives in 7.1, so typescript-eslint / type-coverage / Stryker stay on 6.x
|
||||
@@ -226,7 +230,18 @@ jobs:
|
||||
node-version: ${{ env.CI_NODE_VERSION }}
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- run: npm run test:vitest
|
||||
# WS5.2/5.3: JUnit feeds Trunk Flaky Tests — the fast-path runs on EVERY PR,
|
||||
# which is where flaky-detection volume actually comes from (ci.yml's heavy
|
||||
# jobs only run on the release PR). Advisory upload, own-origin only.
|
||||
- run: npm run test:vitest -- --reporter=default --reporter=junit --outputFile.junit=trunk-junit/vitest-fastpath.xml
|
||||
- name: Upload test results to Trunk (advisory)
|
||||
if: ${{ always() && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) }}
|
||||
continue-on-error: true
|
||||
uses: trunk-io/analytics-uploader@385f1ccdf345b4532dc4b6c665dd432b702b8e28 # v2.1.2
|
||||
with:
|
||||
junit-paths: trunk-junit/**/*.xml
|
||||
org-slug: omniroute
|
||||
token: ${{ secrets.TRUNK_TOKEN }}
|
||||
|
||||
fast-unit:
|
||||
name: Unit Tests fast-path (${{ matrix.shard }}/4)
|
||||
|
||||
1
changelog.d/fixes/7003-jetbrains-ai-loopback-connect.md
Normal file
1
changelog.d/fixes/7003-jetbrains-ai-loopback-connect.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(api): raise the main server's `keepAliveTimeout`/`headersTimeout` well above Node's 5s default so pooled keep-alive clients (e.g. JetBrains AI Assistant's JVM `HttpClient`) stop getting 0 bytes back on a reused connection (#7003)
|
||||
1
changelog.d/fixes/7033-dashboard-typecheck-gate.md
Normal file
1
changelog.d/fixes/7033-dashboard-typecheck-gate.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(ci): add a dashboard-scoped typecheck gate covering `src/app/(dashboard)` TSX, previously invisible to `typecheck:core` and `next build` (#7033)
|
||||
1
changelog.d/maintenance/trunk-flaky-uploads.md
Normal file
1
changelog.d/maintenance/trunk-flaky-uploads.md
Normal file
@@ -0,0 +1 @@
|
||||
- **CI**: Playwright E2E and both vitest suites now emit JUnit and upload to Trunk Flaky Tests (org `omniroute`) — advisory step, own-origin only, uploader action SHA-pinned (WS5.2/5.3 of the quality plan; node:test stays out of the first wave)
|
||||
1
changelog.d/maintenance/trunk-upload-fastpath.md
Normal file
1
changelog.d/maintenance/trunk-upload-fastpath.md
Normal file
@@ -0,0 +1 @@
|
||||
- **CI**: the fast-path Vitest job (every PR) now also emits JUnit and uploads to Trunk Flaky Tests — the heavy-gate uploads alone (release PR only) would never accumulate flaky-detection volume
|
||||
259
config/quality/dashboard-typecheck-baseline.json
Normal file
259
config/quality/dashboard-typecheck-baseline.json
Normal file
@@ -0,0 +1,259 @@
|
||||
{
|
||||
"open-sse/services/payloadRules.ts": {
|
||||
"TS2677": 1
|
||||
},
|
||||
"src/app/(dashboard)/dashboard/HomePageClient.tsx": {
|
||||
"TS2339": 16
|
||||
},
|
||||
"src/app/(dashboard)/dashboard/agent-skills/AgentSkillsPageClient.tsx": {
|
||||
"TS2503": 3
|
||||
},
|
||||
"src/app/(dashboard)/dashboard/agent-skills/components/CoverageBar.tsx": {
|
||||
"TS2503": 1
|
||||
},
|
||||
"src/app/(dashboard)/dashboard/agent-skills/components/McpA2aLinksBar.tsx": {
|
||||
"TS2503": 2
|
||||
},
|
||||
"src/app/(dashboard)/dashboard/agent-skills/components/SkillCard.tsx": {
|
||||
"TS2503": 1
|
||||
},
|
||||
"src/app/(dashboard)/dashboard/agent-skills/components/SkillPreviewPane.tsx": {
|
||||
"TS2503": 2
|
||||
},
|
||||
"src/app/(dashboard)/dashboard/cache/__tests__/CachePage.test.tsx": {
|
||||
"TS2305": 3,
|
||||
"TS1117": 1
|
||||
},
|
||||
"src/app/(dashboard)/dashboard/cache/__tests__/CachePerformance.test.tsx": {
|
||||
"TS2305": 1,
|
||||
"TS2322": 2
|
||||
},
|
||||
"src/app/(dashboard)/dashboard/cache/__tests__/CacheTrends.test.tsx": {
|
||||
"TS2305": 1,
|
||||
"TS2322": 6
|
||||
},
|
||||
"src/app/(dashboard)/dashboard/cache/__tests__/IdempotencyLayer.test.tsx": {
|
||||
"TS2305": 1
|
||||
},
|
||||
"src/app/(dashboard)/dashboard/cache/__tests__/MemoryCards.test.tsx": {
|
||||
"TS2305": 1,
|
||||
"TS2322": 1
|
||||
},
|
||||
"src/app/(dashboard)/dashboard/cache/components/CachePerformance.tsx": {
|
||||
"TS2339": 1
|
||||
},
|
||||
"src/app/(dashboard)/dashboard/cli-code/components/ClaudeToolCard.tsx": {
|
||||
"TS2339": 2
|
||||
},
|
||||
"src/app/(dashboard)/dashboard/cli-code/components/CodexToolCard.tsx": {
|
||||
"TS2345": 3
|
||||
},
|
||||
"src/app/(dashboard)/dashboard/cli-code/components/CustomCliCard.tsx": {
|
||||
"TS2345": 1
|
||||
},
|
||||
"src/app/(dashboard)/dashboard/cli-code/components/DroidToolCard.tsx": {
|
||||
"TS2554": 2
|
||||
},
|
||||
"src/app/(dashboard)/dashboard/combos/page.tsx": {
|
||||
"TS2339": 4,
|
||||
"TS2345": 5,
|
||||
"TS2698": 1,
|
||||
"TS2322": 13
|
||||
},
|
||||
"src/app/(dashboard)/dashboard/compression/studio/EncoderComparisonTable.tsx": {
|
||||
"TS2322": 1
|
||||
},
|
||||
"src/app/(dashboard)/dashboard/costs/CostOverviewTab.tsx": {
|
||||
"TS2304": 1
|
||||
},
|
||||
"src/app/(dashboard)/dashboard/costs/quota-share/QuotaSharePageClient.tsx": {
|
||||
"TS2551": 7,
|
||||
"TS2322": 2,
|
||||
"TS2719": 2,
|
||||
"TS2739": 1
|
||||
},
|
||||
"src/app/(dashboard)/dashboard/costs/quota-share/components/StackedAllocationBar.tsx": {
|
||||
"TS2503": 1
|
||||
},
|
||||
"src/app/(dashboard)/dashboard/costs/quota-share/components/UsageLogCard.tsx": {
|
||||
"TS2869": 2
|
||||
},
|
||||
"src/app/(dashboard)/dashboard/discovery/__tests__/DiscoveryPageClient.test.tsx": {
|
||||
"TS2305": 2
|
||||
},
|
||||
"src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.tsx": {
|
||||
"TS2322": 18
|
||||
},
|
||||
"src/app/(dashboard)/dashboard/memory/components/tabs/MemoriesTab.tsx": {
|
||||
"TS2322": 1
|
||||
},
|
||||
"src/app/(dashboard)/dashboard/omni-skills/OmniSkillsPageClient.tsx": {
|
||||
"TS2503": 1
|
||||
},
|
||||
"src/app/(dashboard)/dashboard/omni-skills/components/OmniExecutionsTab.tsx": {
|
||||
"TS2503": 1
|
||||
},
|
||||
"src/app/(dashboard)/dashboard/omni-skills/components/OmniMarketplaceTab.tsx": {
|
||||
"TS2503": 1
|
||||
},
|
||||
"src/app/(dashboard)/dashboard/omni-skills/components/OmniSandboxTab.tsx": {
|
||||
"TS2503": 1
|
||||
},
|
||||
"src/app/(dashboard)/dashboard/omni-skills/components/OmniSkillCard.tsx": {
|
||||
"TS2503": 1
|
||||
},
|
||||
"src/app/(dashboard)/dashboard/omni-skills/components/OmniSkillsList.tsx": {
|
||||
"TS2503": 1
|
||||
},
|
||||
"src/app/(dashboard)/dashboard/omni-skills/components/SkillInspectorPane.tsx": {
|
||||
"TS2503": 1
|
||||
},
|
||||
"src/app/(dashboard)/dashboard/playground/components/PresetPicker.tsx": {
|
||||
"TS2352": 1
|
||||
},
|
||||
"src/app/(dashboard)/dashboard/playground/components/ToolsBuilder.tsx": {
|
||||
"TS2339": 1
|
||||
},
|
||||
"src/app/(dashboard)/dashboard/providers/[id]/ProviderDetailPageClient.tsx": {
|
||||
"TS2322": 4
|
||||
},
|
||||
"src/app/(dashboard)/dashboard/providers/[id]/__tests__/phase1e.test.tsx": {
|
||||
"TS2741": 2
|
||||
},
|
||||
"src/app/(dashboard)/dashboard/providers/[id]/components/CompatibleModelsSection.tsx": {
|
||||
"TS2741": 1
|
||||
},
|
||||
"src/app/(dashboard)/dashboard/providers/[id]/components/ConnectionRow.tsx": {
|
||||
"TS2345": 3,
|
||||
"TS2322": 1
|
||||
},
|
||||
"src/app/(dashboard)/dashboard/providers/[id]/components/ConnectionsListPanel.tsx": {
|
||||
"TS2322": 2
|
||||
},
|
||||
"src/app/(dashboard)/dashboard/providers/[id]/components/CustomModelsSection.tsx": {
|
||||
"TS2739": 1
|
||||
},
|
||||
"src/app/(dashboard)/dashboard/providers/[id]/components/ModelCompatPopover.tsx": {
|
||||
"TS2304": 5
|
||||
},
|
||||
"src/app/(dashboard)/dashboard/providers/[id]/components/ProviderModalsPanel.tsx": {
|
||||
"TS2322": 3,
|
||||
"TS2739": 1,
|
||||
"TS2345": 3
|
||||
},
|
||||
"src/app/(dashboard)/dashboard/providers/[id]/components/ProviderModelsSection.tsx": {
|
||||
"TS2322": 1
|
||||
},
|
||||
"src/app/(dashboard)/dashboard/providers/[id]/components/ProviderParamFilterSection.tsx": {
|
||||
"TS2339": 6
|
||||
},
|
||||
"src/app/(dashboard)/dashboard/providers/[id]/components/ProviderPlaygroundPanel.tsx": {
|
||||
"TS2503": 1
|
||||
},
|
||||
"src/app/(dashboard)/dashboard/providers/[id]/components/__tests__/phase1d.test.tsx": {
|
||||
"TS2739": 2
|
||||
},
|
||||
"src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditConnectionModal.tsx": {
|
||||
"TS2322": 1
|
||||
},
|
||||
"src/app/(dashboard)/dashboard/providers/[id]/hooks/useModelImportHandlers.ts": {
|
||||
"TS2339": 1
|
||||
},
|
||||
"src/app/(dashboard)/dashboard/providers/[id]/hooks/useModelVisibilityHandlers.ts": {
|
||||
"TS2339": 15
|
||||
},
|
||||
"src/app/(dashboard)/dashboard/providers/components/onboarding/providerOnboardingCatalog.ts": {
|
||||
"TS2339": 4,
|
||||
"TS2345": 2
|
||||
},
|
||||
"src/app/(dashboard)/dashboard/providers/providerPageUtils.ts": {
|
||||
"TS2345": 1
|
||||
},
|
||||
"src/app/(dashboard)/dashboard/providers/services/components/CliproxyModelMappingEditor.tsx": {
|
||||
"TS2339": 1
|
||||
},
|
||||
"src/app/(dashboard)/dashboard/quota/page.tsx": {
|
||||
"TS2339": 4
|
||||
},
|
||||
"src/app/(dashboard)/dashboard/runtime/RuntimePageClient.tsx": {
|
||||
"TS2304": 1
|
||||
},
|
||||
"src/app/(dashboard)/dashboard/settings/components/AppearanceTab.tsx": {
|
||||
"TS2339": 4
|
||||
},
|
||||
"src/app/(dashboard)/dashboard/settings/components/RedisLauncherPanel.tsx": {
|
||||
"TS2345": 11
|
||||
},
|
||||
"src/app/(dashboard)/dashboard/settings/components/SidebarTab.tsx": {
|
||||
"TS2322": 1
|
||||
},
|
||||
"src/app/(dashboard)/dashboard/settings/components/proxy/FreePoolTab.tsx": {
|
||||
"TS2304": 1
|
||||
},
|
||||
"src/app/(dashboard)/dashboard/tools/traffic-inspector/components/CustomHostsManager.tsx": {
|
||||
"TS2339": 1
|
||||
},
|
||||
"src/app/(dashboard)/dashboard/tools/traffic-inspector/components/tabs/ResponseBodyTab.tsx": {
|
||||
"TS2339": 5
|
||||
},
|
||||
"src/app/(dashboard)/dashboard/translator/components/MonitorTab.tsx": {
|
||||
"TS2345": 1
|
||||
},
|
||||
"src/app/(dashboard)/dashboard/translator/components/advanced/CompressionPreviewAccordion.tsx": {
|
||||
"TS4104": 1
|
||||
},
|
||||
"src/app/(dashboard)/dashboard/translator/components/advanced/StreamTransformerAccordion.tsx": {
|
||||
"TS2345": 1
|
||||
},
|
||||
"src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.tsx": {
|
||||
"TS2339": 2
|
||||
},
|
||||
"src/app/(dashboard)/dashboard/usage/components/ProviderLimits/parts/QuotaEnvGroup.tsx": {
|
||||
"TS2739": 1
|
||||
},
|
||||
"src/lib/combos/builderDraft.ts": {
|
||||
"TS2741": 1
|
||||
},
|
||||
"src/lib/providers/codexFastTier.ts": {
|
||||
"TS2367": 1
|
||||
},
|
||||
"src/lib/services/htmlRewriter.ts": {
|
||||
"TS2322": 2,
|
||||
"TS2345": 2
|
||||
},
|
||||
"src/mitm/inspector/sseMerger.ts": {
|
||||
"TS2352": 1
|
||||
},
|
||||
"src/shared/components/Header.tsx": {
|
||||
"TS2353": 1
|
||||
},
|
||||
"src/shared/components/MonacoEditor.tsx": {
|
||||
"TS2307": 1
|
||||
},
|
||||
"src/shared/components/OAuthModal.tsx": {
|
||||
"TS2769": 4,
|
||||
"TS2345": 4
|
||||
},
|
||||
"src/shared/components/SkillsConceptCard.tsx": {
|
||||
"TS2503": 1
|
||||
},
|
||||
"src/shared/components/analytics/charts.tsx": {
|
||||
"TS2345": 1
|
||||
},
|
||||
"src/shared/components/analytics/rechartsDonuts.tsx": {
|
||||
"TS2739": 2
|
||||
},
|
||||
"src/shared/hooks/useElectron.ts": {
|
||||
"TS2339": 19
|
||||
},
|
||||
"src/shared/providers/webSessionCredentials.ts": {
|
||||
"TS2353": 1,
|
||||
"TS2322": 1
|
||||
},
|
||||
"src/shared/schemas/cliCatalog.ts": {
|
||||
"TS2554": 2
|
||||
},
|
||||
"src/shared/services/opencodeConfig.ts": {
|
||||
"TS2345": 1
|
||||
}
|
||||
}
|
||||
@@ -46,6 +46,7 @@ Runs on every PR to `main`. Blocks merge on failure.
|
||||
| `check:docs-sync` | CHANGELOG version, OpenAPI version, and `llm.txt` are in sync | Yes |
|
||||
| `typecheck:core` | TypeScript compilation without errors (advisory warnings only) | Yes |
|
||||
| `typecheck:noimplicit:core` | Strict `noImplicitAny` — forward-looking; many pre-existing call sites still need annotations | **Advisory** (`continue-on-error: true`) |
|
||||
| `check:dashboard-typecheck` | `tsc` scoped to `src/app/(dashboard)/**` (#7033) — `typecheck:core`'s curated 27-file allowlist does not include any dashboard TSX, and `next build` never type-checks it either (`next.config.mjs` sets `ignoreBuildErrors: true`), so orphaned-identifier regressions there (#6625/#6909) were invisible to CI. Diffs against a frozen per-file/per-TS-code count baseline (`config/quality/dashboard-typecheck-baseline.json`, same stale-enforcement pattern as `check:known-symbols`) — only NEW errors beyond the baselined count fail the gate; ratchet down with `--update` when a pre-existing error is fixed. | Yes |
|
||||
|
||||
### Job: `quality-gate`
|
||||
|
||||
|
||||
@@ -185,6 +185,7 @@
|
||||
"audit:electron": "npm --prefix electron audit --audit-level=critical && (npm --prefix electron audit --audit-level=high || echo '::warning::electron high-severity advisories present (non-blocking)')",
|
||||
"typecheck:core": "tsc --pretty false -p tsconfig.typecheck-core.json",
|
||||
"typecheck:noimplicit:core": "tsc --pretty false -p tsconfig.typecheck-noimplicit-core.json",
|
||||
"check:dashboard-typecheck": "node scripts/check/check-dashboard-typecheck.mjs",
|
||||
"backfill-aggregation": "node --import tsx src/scripts/backfillAggregation.ts",
|
||||
"env:sync": "node scripts/dev/sync-env.mjs",
|
||||
"test:integration": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=1 tests/integration/*.test.ts \"tests/integration/combo-matrix/*.test.ts\"",
|
||||
|
||||
177
scripts/check/check-dashboard-typecheck.mjs
Normal file
177
scripts/check/check-dashboard-typecheck.mjs
Normal file
@@ -0,0 +1,177 @@
|
||||
#!/usr/bin/env node
|
||||
// scripts/check/check-dashboard-typecheck.mjs
|
||||
// Dashboard-scoped typecheck gate (#7033).
|
||||
//
|
||||
// `typecheck:core` (the only blocking CI typecheck gate) runs against a curated
|
||||
// 27-file `"files"` allowlist in tsconfig.typecheck-core.json — none of it lives
|
||||
// under `src/app/(dashboard)`, and `next.config.mjs` sets
|
||||
// `typescript.ignoreBuildErrors: true`, so `next build` never type-checks either.
|
||||
// Net effect: orphaned-identifier regressions in dashboard TSX (deleted `useState`
|
||||
// decls with live usages left behind) are invisible to both CI type-check paths
|
||||
// and only surface at runtime — exactly what happened in #6625/#6909.
|
||||
//
|
||||
// This gate runs `tsc` scoped to `src/app/(dashboard)/**/*.{ts,tsx}` via
|
||||
// tsconfig.typecheck-dashboard.json and diffs the result against a frozen
|
||||
// per-file/per-TS-code count baseline (config/quality/dashboard-typecheck-baseline.json),
|
||||
// following this repo's stale-enforcement allowlist convention (see
|
||||
// scripts/check/check-known-symbols.ts). A live count that EXCEEDS the baselined
|
||||
// count for a given (file, TS code) pair is a regression and fails the gate; a
|
||||
// live count that is lower is an improvement and does not fail (use --update to
|
||||
// ratchet the baseline down).
|
||||
//
|
||||
// Run:
|
||||
// node scripts/check/check-dashboard-typecheck.mjs
|
||||
// node scripts/check/check-dashboard-typecheck.mjs --update # re-freeze baseline
|
||||
|
||||
import { execFileSync } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
const ROOT = process.cwd();
|
||||
const TSCONFIG = path.join(ROOT, "tsconfig.typecheck-dashboard.json");
|
||||
const BASELINE_PATH = path.join(ROOT, "config/quality/dashboard-typecheck-baseline.json");
|
||||
const UPDATE = process.argv.includes("--update");
|
||||
|
||||
// Matches tsc --pretty false output lines, e.g.:
|
||||
// src/app/(dashboard)/dashboard/foo.tsx(12,7): error TS2304: Cannot find name 'bar'.
|
||||
const TSC_ERROR_LINE = /^(.+?)\((\d+),(\d+)\): error (TS\d+):/;
|
||||
|
||||
/**
|
||||
* Parses raw `tsc --pretty false` stdout into a nested count map:
|
||||
* { "<relative file path>": { "<TS code>": <count> } }
|
||||
*
|
||||
* Pure/exported for unit testing against synthetic tsc output — no child
|
||||
* process involved here.
|
||||
*/
|
||||
export function parseTscOutput(raw) {
|
||||
const counts = {};
|
||||
const lines = String(raw).split("\n");
|
||||
for (const line of lines) {
|
||||
const match = TSC_ERROR_LINE.exec(line);
|
||||
if (!match) continue;
|
||||
const [, file, , , code] = match;
|
||||
if (!counts[file]) counts[file] = {};
|
||||
counts[file][code] = (counts[file][code] || 0) + 1;
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compares live (file, TS code) error counts against a frozen baseline.
|
||||
* Returns `{ regressions, improvements }`:
|
||||
* - regressions: entries where live count > baselined count (or the pair is
|
||||
* entirely new/unbaselined) — these fail the gate.
|
||||
* - improvements: entries where live count < baselined count — informational,
|
||||
* do not fail (use --update to ratchet the baseline down).
|
||||
*
|
||||
* Exported for unit testing.
|
||||
*/
|
||||
export function diffAgainstBaseline(live, baseline) {
|
||||
const regressions = [];
|
||||
const improvements = [];
|
||||
|
||||
for (const [file, codes] of Object.entries(live)) {
|
||||
for (const [code, liveCount] of Object.entries(codes)) {
|
||||
const baselineCount = (baseline[file] && baseline[file][code]) || 0;
|
||||
if (liveCount > baselineCount) {
|
||||
regressions.push({ file, code, liveCount, baselineCount });
|
||||
} else if (liveCount < baselineCount) {
|
||||
improvements.push({ file, code, liveCount, baselineCount });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const [file, codes] of Object.entries(baseline)) {
|
||||
for (const [code, baselineCount] of Object.entries(codes)) {
|
||||
const liveCount = (live[file] && live[file][code]) || 0;
|
||||
if (liveCount === 0 && baselineCount > 0) {
|
||||
improvements.push({ file, code, liveCount: 0, baselineCount });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { regressions, improvements };
|
||||
}
|
||||
|
||||
function runTsc() {
|
||||
try {
|
||||
const stdout = execFileSync(
|
||||
process.platform === "win32" ? "npx.cmd" : "npx",
|
||||
["tsc", "--pretty", "false", "--noEmit", "-p", TSCONFIG],
|
||||
{ encoding: "utf8", maxBuffer: 64 * 1024 * 1024, cwd: ROOT }
|
||||
);
|
||||
return stdout;
|
||||
} catch (err) {
|
||||
// tsc exits non-zero when there are type errors — stdout still has the report.
|
||||
if (err.stdout) return String(err.stdout);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
function loadBaseline() {
|
||||
if (!fs.existsSync(BASELINE_PATH)) return {};
|
||||
return JSON.parse(fs.readFileSync(BASELINE_PATH, "utf8"));
|
||||
}
|
||||
|
||||
function writeBaseline(counts) {
|
||||
fs.writeFileSync(BASELINE_PATH, JSON.stringify(counts, null, 2) + "\n");
|
||||
}
|
||||
|
||||
function main() {
|
||||
if (!fs.existsSync(TSCONFIG)) {
|
||||
process.stderr.write(`[dashboard-typecheck] FAIL — tsconfig not found at ${TSCONFIG}\n`);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
console.log("[dashboard-typecheck] Running tsc scoped to src/app/(dashboard)/**…");
|
||||
const stdout = runTsc();
|
||||
const live = parseTscOutput(stdout);
|
||||
const baseline = loadBaseline();
|
||||
const { regressions, improvements } = diffAgainstBaseline(live, baseline);
|
||||
|
||||
const liveErrorCount = Object.values(live).reduce(
|
||||
(sum, codes) => sum + Object.values(codes).reduce((s, c) => s + c, 0),
|
||||
0
|
||||
);
|
||||
console.log(`dashboardTypecheckErrors=${liveErrorCount}`);
|
||||
|
||||
if (UPDATE) {
|
||||
writeBaseline(live);
|
||||
console.log(`[dashboard-typecheck] baseline rewritten (${liveErrorCount} errors frozen).`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (improvements.length > 0) {
|
||||
console.log(
|
||||
`[dashboard-typecheck] ${improvements.length} baselined error(s) no longer present ` +
|
||||
`— run 'node scripts/check/check-dashboard-typecheck.mjs --update' to ratchet the baseline down:\n` +
|
||||
improvements
|
||||
.map((i) => ` - ${i.file} ${i.code} (baseline ${i.baselineCount} -> live ${i.liveCount})`)
|
||||
.join("\n")
|
||||
);
|
||||
}
|
||||
|
||||
if (regressions.length > 0) {
|
||||
process.stderr.write(
|
||||
`[dashboard-typecheck] FAIL — ${regressions.length} new/regressed TypeScript error(s) ` +
|
||||
`under src/app/(dashboard)/ not covered by the frozen baseline:\n` +
|
||||
regressions
|
||||
.map((r) => ` ✗ ${r.file} ${r.code} (baseline ${r.baselineCount}, live ${r.liveCount})`)
|
||||
.join("\n") +
|
||||
`\n\nIf this is a genuine new dashboard TSX bug (e.g. an orphaned identifier), fix it.\n` +
|
||||
`If it's pre-existing type looseness you're intentionally not fixing in this PR,\n` +
|
||||
`do NOT widen the baseline for new regressions — that defeats the gate.\n`
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[dashboard-typecheck] OK — ${liveErrorCount} pre-existing error(s), all within frozen baseline.`
|
||||
);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (import.meta.url === pathToFileURL(process.argv[1] || "").href) {
|
||||
main();
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import headResponseGuard from "./head-response-guard.cjs";
|
||||
import { ensureNativeSqlite } from "./ensure-native-sqlite.mjs";
|
||||
import { isTurbopackCacheCorruption, purgeAllTurbopackCaches } from "./turbopackCacheHeal.mjs";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { getMainServerTimeoutConfig } from "../../src/shared/utils/runtimeTimeouts.ts";
|
||||
|
||||
const { maybeHandleDisallowedMethod } = methodGuard;
|
||||
const { wrapRequestListenerWithHeadResponseGuard } = headResponseGuard;
|
||||
@@ -153,6 +154,15 @@ async function start() {
|
||||
return requestHandler(req, res);
|
||||
})
|
||||
);
|
||||
// Node's http.Server default keepAliveTimeout (5_000ms) races pooled
|
||||
// keep-alive HTTP clients that idle longer than that between requests (e.g.
|
||||
// the JVM java.net.http.HttpClient used by JetBrains AI Assistant), which
|
||||
// reuse a socket the server already tore down and get 0 response bytes back
|
||||
// (#7003). Raise both timeouts well above any realistic client idle-pool
|
||||
// window, mirroring src/lib/apiBridgeServer.ts's pattern.
|
||||
const mainServerTimeouts = getMainServerTimeoutConfig();
|
||||
server.keepAliveTimeout = mainServerTimeouts.keepAliveTimeoutMs;
|
||||
server.headersTimeout = mainServerTimeouts.headersTimeoutMs;
|
||||
server.on("upgrade", async (req, socket, head) => {
|
||||
try {
|
||||
const responsesWsHandled = await responsesWsProxy.handleUpgrade(req, socket, head);
|
||||
|
||||
@@ -7,6 +7,7 @@ import { maybeHandleWebdav } from "./webdav-handler.mjs";
|
||||
import methodGuard from "./http-method-guard.cjs";
|
||||
import headResponseGuard from "./head-response-guard.cjs";
|
||||
import { resolveTlsOptions, createServerListener } from "./tls-options.mjs";
|
||||
import { getMainServerTimeoutConfig } from "../../src/shared/utils/runtimeTimeouts.ts";
|
||||
|
||||
const originalCreateServer = http.createServer.bind(http);
|
||||
const proxiesByPort = new Map();
|
||||
@@ -151,6 +152,19 @@ http.createServer = function createServerWithResponsesWs(...args) {
|
||||
// listener); otherwise the original http.Server. The downstream .on/.addListener
|
||||
// patches below apply identically to both (https.Server extends http.Server).
|
||||
const server = createServerListener(args, tlsOptions, { createHttp: originalCreateServer });
|
||||
// Node's http.Server default keepAliveTimeout (5_000ms) races pooled
|
||||
// keep-alive HTTP clients that idle longer than that between requests (e.g.
|
||||
// the JVM java.net.http.HttpClient used by JetBrains AI Assistant), which
|
||||
// reuse a socket the server already tore down and get 0 response bytes back
|
||||
// (#7003). This wrapper is what `omniroute serve` / Docker / Electron actually
|
||||
// spawn in production (run-standalone.mjs prefers server-ws.mjs over the bare
|
||||
// Next server.js), so it needs the same fix already wired into run-next.mjs
|
||||
// (the dev-only entry point) — otherwise real installs never got it. Raise
|
||||
// both timeouts well above any realistic client idle-pool window, mirroring
|
||||
// src/lib/apiBridgeServer.ts's pattern.
|
||||
const mainServerTimeouts = getMainServerTimeoutConfig();
|
||||
server.keepAliveTimeout = mainServerTimeouts.keepAliveTimeoutMs;
|
||||
server.headersTimeout = mainServerTimeouts.headersTimeoutMs;
|
||||
const originalOn = server.on.bind(server);
|
||||
const originalAddListener = server.addListener.bind(server);
|
||||
|
||||
|
||||
@@ -19,6 +19,14 @@ export const DEFAULT_API_BRIDGE_SERVER_REQUEST_TIMEOUT_MS = 300_000;
|
||||
export const DEFAULT_API_BRIDGE_SERVER_HEADERS_TIMEOUT_MS = 60_000;
|
||||
export const DEFAULT_API_BRIDGE_SERVER_KEEPALIVE_TIMEOUT_MS = 5_000;
|
||||
export const DEFAULT_API_BRIDGE_SERVER_SOCKET_TIMEOUT_MS = 0;
|
||||
// Node's http.Server default keepAliveTimeout is 5_000ms with no Keep-Alive
|
||||
// response header hint. Pooled keep-alive clients that don't race that exact
|
||||
// window (e.g. the JVM java.net.http.HttpClient used by JetBrains AI
|
||||
// Assistant) can reuse a socket the server has already torn down, getting 0
|
||||
// response bytes back (#7003). Raise both well above any realistic client
|
||||
// idle-pool window, mirroring the API bridge server's pattern.
|
||||
export const DEFAULT_MAIN_SERVER_KEEPALIVE_TIMEOUT_MS = 65_000;
|
||||
export const DEFAULT_MAIN_SERVER_HEADERS_TIMEOUT_MS = 66_000;
|
||||
|
||||
function hasEnvValue(env: EnvSource, name: string): boolean {
|
||||
const raw = env[name];
|
||||
@@ -49,6 +57,11 @@ export type ApiBridgeTimeoutConfig = {
|
||||
serverSocketTimeoutMs: number;
|
||||
};
|
||||
|
||||
export type MainServerTimeoutConfig = {
|
||||
keepAliveTimeoutMs: number;
|
||||
headersTimeoutMs: number;
|
||||
};
|
||||
|
||||
function readTimeoutMs(
|
||||
env: EnvSource,
|
||||
name: string,
|
||||
@@ -255,3 +268,37 @@ export function getApiBridgeTimeoutConfig(
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export function getMainServerTimeoutConfig(
|
||||
env: EnvSource = process.env,
|
||||
logger?: TimeoutLogger
|
||||
): MainServerTimeoutConfig {
|
||||
const keepAliveTimeoutMs = readTimeoutMs(
|
||||
env,
|
||||
"MAIN_SERVER_KEEPALIVE_TIMEOUT_MS",
|
||||
DEFAULT_MAIN_SERVER_KEEPALIVE_TIMEOUT_MS,
|
||||
{
|
||||
allowZero: true,
|
||||
logger,
|
||||
}
|
||||
);
|
||||
const headersTimeoutMs = readTimeoutMs(
|
||||
env,
|
||||
"MAIN_SERVER_HEADERS_TIMEOUT_MS",
|
||||
DEFAULT_MAIN_SERVER_HEADERS_TIMEOUT_MS,
|
||||
{
|
||||
allowZero: true,
|
||||
logger,
|
||||
}
|
||||
);
|
||||
|
||||
return {
|
||||
keepAliveTimeoutMs,
|
||||
// Node requires headersTimeout > keepAliveTimeout to avoid its internal
|
||||
// race-condition warning; keep both configurable but always coherent.
|
||||
headersTimeoutMs:
|
||||
headersTimeoutMs > 0 && keepAliveTimeoutMs > 0
|
||||
? Math.max(headersTimeoutMs, keepAliveTimeoutMs + 1_000)
|
||||
: headersTimeoutMs,
|
||||
};
|
||||
}
|
||||
|
||||
111
tests/unit/build/check-dashboard-typecheck.test.ts
Normal file
111
tests/unit/build/check-dashboard-typecheck.test.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
// tests/unit/build/check-dashboard-typecheck.test.ts
|
||||
// Unit tests for the pure parsing/diff helpers in check-dashboard-typecheck.mjs.
|
||||
// No child process is spawned — synthetic tsc-style output only, so the suite is
|
||||
// fast and hermetic. Proves the gate actually DETECTS the #6625/#6909 bug class
|
||||
// (an orphaned identifier — used but not declared — in a dashboard TSX file),
|
||||
// not just that the script runs.
|
||||
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
parseTscOutput,
|
||||
diffAgainstBaseline,
|
||||
} from "../../../scripts/check/check-dashboard-typecheck.mjs";
|
||||
|
||||
test("parseTscOutput: parses a TS2304 orphaned-identifier error (the #6625/#6909 bug class)", () => {
|
||||
const raw =
|
||||
`src/app/(dashboard)/dashboard/settings/components/ProxyRegistryManager.tsx(564,7): error TS2304: Cannot find name 'setPoolLoaded'.\n` +
|
||||
`src/app/(dashboard)/dashboard/settings/components/ProxyRegistryManager.tsx(1204,12): error TS2304: Cannot find name 'poolLoaded'.\n`;
|
||||
|
||||
const counts = parseTscOutput(raw);
|
||||
|
||||
assert.deepEqual(counts, {
|
||||
"src/app/(dashboard)/dashboard/settings/components/ProxyRegistryManager.tsx": {
|
||||
TS2304: 2,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("parseTscOutput: ignores non-error lines (summary/info output)", () => {
|
||||
const raw =
|
||||
`Some info line that is not an error\n` +
|
||||
`src/app/(dashboard)/dashboard/foo.tsx(1,1): error TS2339: Property 'bar' does not exist.\n` +
|
||||
`Found 1 error in 1 file.\n`;
|
||||
|
||||
const counts = parseTscOutput(raw);
|
||||
|
||||
assert.deepEqual(counts, {
|
||||
"src/app/(dashboard)/dashboard/foo.tsx": { TS2339: 1 },
|
||||
});
|
||||
});
|
||||
|
||||
test("parseTscOutput: returns empty map for clean output", () => {
|
||||
assert.deepEqual(parseTscOutput(""), {});
|
||||
assert.deepEqual(parseTscOutput("Found 0 errors.\n"), {});
|
||||
});
|
||||
|
||||
test("diffAgainstBaseline: flags a brand-new orphaned-identifier error as a regression", () => {
|
||||
const baseline = {};
|
||||
const live = {
|
||||
"src/app/(dashboard)/dashboard/settings/components/ProxyRegistryManager.tsx": {
|
||||
TS2304: 5,
|
||||
},
|
||||
};
|
||||
|
||||
const { regressions, improvements } = diffAgainstBaseline(live, baseline);
|
||||
|
||||
assert.equal(regressions.length, 1);
|
||||
assert.equal(
|
||||
regressions[0].file,
|
||||
"src/app/(dashboard)/dashboard/settings/components/ProxyRegistryManager.tsx"
|
||||
);
|
||||
assert.equal(regressions[0].code, "TS2304");
|
||||
assert.equal(regressions[0].liveCount, 5);
|
||||
assert.equal(regressions[0].baselineCount, 0);
|
||||
assert.equal(improvements.length, 0);
|
||||
});
|
||||
|
||||
test("diffAgainstBaseline: does NOT flag a frozen pre-existing error within its baselined count", () => {
|
||||
const baseline = { "src/app/(dashboard)/dashboard/foo.tsx": { TS2339: 3 } };
|
||||
const live = { "src/app/(dashboard)/dashboard/foo.tsx": { TS2339: 3 } };
|
||||
|
||||
const { regressions, improvements } = diffAgainstBaseline(live, baseline);
|
||||
|
||||
assert.equal(regressions.length, 0);
|
||||
assert.equal(improvements.length, 0);
|
||||
});
|
||||
|
||||
test("diffAgainstBaseline: flags a count INCREASE beyond the frozen baseline as a regression", () => {
|
||||
const baseline = { "src/app/(dashboard)/dashboard/foo.tsx": { TS2339: 2 } };
|
||||
const live = { "src/app/(dashboard)/dashboard/foo.tsx": { TS2339: 3 } };
|
||||
|
||||
const { regressions } = diffAgainstBaseline(live, baseline);
|
||||
|
||||
assert.equal(regressions.length, 1);
|
||||
assert.equal(regressions[0].baselineCount, 2);
|
||||
assert.equal(regressions[0].liveCount, 3);
|
||||
});
|
||||
|
||||
test("diffAgainstBaseline: reports (does not fail on) a count DECREASE as an improvement", () => {
|
||||
const baseline = { "src/app/(dashboard)/dashboard/foo.tsx": { TS2339: 3 } };
|
||||
const live = { "src/app/(dashboard)/dashboard/foo.tsx": { TS2339: 1 } };
|
||||
|
||||
const { regressions, improvements } = diffAgainstBaseline(live, baseline);
|
||||
|
||||
assert.equal(regressions.length, 0);
|
||||
assert.equal(improvements.length, 1);
|
||||
assert.equal(improvements[0].baselineCount, 3);
|
||||
assert.equal(improvements[0].liveCount, 1);
|
||||
});
|
||||
|
||||
test("diffAgainstBaseline: a baselined error that fully disappears is reported as an improvement, not a failure", () => {
|
||||
const baseline = { "src/app/(dashboard)/dashboard/foo.tsx": { TS2339: 2 } };
|
||||
const live = {};
|
||||
|
||||
const { regressions, improvements } = diffAgainstBaseline(live, baseline);
|
||||
|
||||
assert.equal(regressions.length, 0);
|
||||
assert.equal(improvements.length, 1);
|
||||
assert.equal(improvements[0].liveCount, 0);
|
||||
assert.equal(improvements[0].baselineCount, 2);
|
||||
});
|
||||
202
tests/unit/main-server-keepalive-timeout-7003.test.ts
Normal file
202
tests/unit/main-server-keepalive-timeout-7003.test.ts
Normal file
@@ -0,0 +1,202 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import http from "node:http";
|
||||
import net from "node:net";
|
||||
import { getMainServerTimeoutConfig } from "../../src/shared/utils/runtimeTimeouts.ts";
|
||||
|
||||
// #7003 — JetBrains AI Assistant ("Test Connection" / completions) reported
|
||||
// "HTTP/1.1 header parser received no bytes". The main OmniRoute server
|
||||
// (scripts/dev/run-next.mjs) boots a bare `http.createServer(...)` and never
|
||||
// configures `keepAliveTimeout`/`headersTimeout`, leaving Node's http.Server
|
||||
// default of keepAliveTimeout=5_000ms with no `Keep-Alive: timeout=N` response
|
||||
// hint. JetBrains AI Assistant's JVM `java.net.http.HttpClient` connection pool
|
||||
// can reuse a socket idle for longer than that window; the server has already
|
||||
// torn the socket down, so the client gets 0 response bytes back instead of a
|
||||
// fresh HTTP response.
|
||||
//
|
||||
// This spec proves both halves:
|
||||
// 1. `getMainServerTimeoutConfig()` raises the defaults well above Node's
|
||||
// unconfigured 5_000ms window (the actual fix wired into run-next.mjs).
|
||||
// 2. A bare http.Server left at Node's defaults drops a socket reused after
|
||||
// an idle gap past 5s, while the same server configured via
|
||||
// `getMainServerTimeoutConfig()` keeps serving the reused connection.
|
||||
|
||||
describe("#7003 getMainServerTimeoutConfig", () => {
|
||||
it("defaults keepAliveTimeout/headersTimeout well above Node's 5_000ms default", () => {
|
||||
const config = getMainServerTimeoutConfig({});
|
||||
assert.equal(config.keepAliveTimeoutMs, 65_000);
|
||||
assert.equal(config.headersTimeoutMs, 66_000);
|
||||
assert.ok(config.keepAliveTimeoutMs > 5_000, "must exceed Node's unconfigured default");
|
||||
assert.ok(
|
||||
config.headersTimeoutMs > config.keepAliveTimeoutMs,
|
||||
"headersTimeout must stay above keepAliveTimeout per Node's own requirement"
|
||||
);
|
||||
});
|
||||
|
||||
it("honors env overrides and keeps headersTimeout coherent with a raised keepAliveTimeout", () => {
|
||||
const config = getMainServerTimeoutConfig({
|
||||
MAIN_SERVER_KEEPALIVE_TIMEOUT_MS: "120000",
|
||||
MAIN_SERVER_HEADERS_TIMEOUT_MS: "121000",
|
||||
});
|
||||
assert.equal(config.keepAliveTimeoutMs, 120_000);
|
||||
assert.equal(config.headersTimeoutMs, 121_000);
|
||||
});
|
||||
|
||||
it("bumps an inconsistent explicit headersTimeout override above keepAliveTimeout", () => {
|
||||
const config = getMainServerTimeoutConfig({
|
||||
MAIN_SERVER_KEEPALIVE_TIMEOUT_MS: "120000",
|
||||
MAIN_SERVER_HEADERS_TIMEOUT_MS: "1000",
|
||||
});
|
||||
assert.equal(config.keepAliveTimeoutMs, 120_000);
|
||||
assert.equal(config.headersTimeoutMs, 121_000);
|
||||
});
|
||||
|
||||
it("falls back to defaults on invalid env values", () => {
|
||||
const config = getMainServerTimeoutConfig({
|
||||
MAIN_SERVER_KEEPALIVE_TIMEOUT_MS: "not-a-number",
|
||||
});
|
||||
assert.equal(config.keepAliveTimeoutMs, 65_000);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Sends a raw HTTP/1.1 GET over an already-connected keep-alive socket and
|
||||
* resolves with whatever bytes arrive within a short settle window (empty
|
||||
* string if nothing comes back — the exact "0 bytes back" failure mode
|
||||
* JetBrains AI Assistant surfaces as "header parser received no bytes").
|
||||
*
|
||||
* The socket is opened with `allowHalfOpen: true` so it faithfully mimics a
|
||||
* JVM/OkHttp-style client: Node's default `allowHalfOpen: false` proactively
|
||||
* ends the writable side the instant it processes an incoming FIN, turning
|
||||
* the reused write into a synchronous "socket has been ended" error instead
|
||||
* of the real-world race — a write that is accepted locally (the server
|
||||
* already destroyed the connection, so it never arrives) whose response
|
||||
* settles as 0 bytes.
|
||||
*/
|
||||
function sendKeepAliveRequest(socket: net.Socket, port: number): Promise<string> {
|
||||
return new Promise((resolve) => {
|
||||
let received = "";
|
||||
let settleTimer: NodeJS.Timeout;
|
||||
const finish = () => {
|
||||
socket.off("data", onData);
|
||||
clearTimeout(settleTimer);
|
||||
resolve(received);
|
||||
};
|
||||
// A short settle window once the full chunked response has arrived (fast
|
||||
// path); a generous cap in case nothing ever comes back — the torn-down
|
||||
// connection case this test proves, and a safety margin against first-run
|
||||
// JIT/module-load jitter under the test runner.
|
||||
const onData = (chunk: Buffer) => {
|
||||
received += chunk.toString("utf8");
|
||||
if (received.endsWith("0\r\n\r\n")) {
|
||||
clearTimeout(settleTimer);
|
||||
settleTimer = setTimeout(finish, 50);
|
||||
}
|
||||
};
|
||||
socket.on("data", onData);
|
||||
socket.write(`GET / HTTP/1.1\r\nHost: 127.0.0.1:${port}\r\nConnection: keep-alive\r\n\r\n`);
|
||||
settleTimer = setTimeout(finish, 3_000);
|
||||
});
|
||||
}
|
||||
|
||||
function startEchoServer(configure: (server: http.Server) => void): Promise<http.Server> {
|
||||
return new Promise((resolve) => {
|
||||
const server = http.createServer((_req, res) => {
|
||||
res.writeHead(200, { "content-type": "text/plain" });
|
||||
res.end("ok");
|
||||
});
|
||||
configure(server);
|
||||
server.listen(0, "127.0.0.1", () => resolve(server));
|
||||
});
|
||||
}
|
||||
|
||||
async function withServer(
|
||||
configure: (server: http.Server) => void,
|
||||
run: (port: number) => Promise<void>
|
||||
): Promise<void> {
|
||||
const server = await startEchoServer(configure);
|
||||
try {
|
||||
const address = server.address();
|
||||
if (typeof address !== "object" || address === null) {
|
||||
throw new Error("expected server to bind a TCP address");
|
||||
}
|
||||
await run(address.port);
|
||||
} finally {
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
}
|
||||
}
|
||||
|
||||
// Node's default keepAliveTimeout is 5_000ms, but the server only starts that
|
||||
// timer once the response has fully flushed and there is a small amount of
|
||||
// internal scheduling overhead before the socket is actually torn down —
|
||||
// empirically ~5.8-6s end-to-end on loopback. 6.5s reliably clears that
|
||||
// window without relying on a hair-trigger race.
|
||||
const IDLE_GAP_MS = 6_500;
|
||||
|
||||
describe("#7003 keep-alive socket reuse across an idle gap", () => {
|
||||
it(
|
||||
"current Node defaults (keepAliveTimeout=5000ms): a pooled socket reused after 6.5s idle gets 0 bytes back",
|
||||
{ timeout: 30_000 },
|
||||
async () => {
|
||||
await withServer(
|
||||
() => {
|
||||
/* leave Node's http.Server defaults untouched (keepAliveTimeout=5000ms) */
|
||||
},
|
||||
async (port) => {
|
||||
const socket = net.connect({ port, host: "127.0.0.1", allowHalfOpen: true });
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
socket.once("connect", () => resolve());
|
||||
socket.once("error", reject);
|
||||
});
|
||||
|
||||
const first = await sendKeepAliveRequest(socket, port);
|
||||
assert.match(first, /200/, "first request on a fresh socket must succeed");
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, IDLE_GAP_MS));
|
||||
|
||||
const second = await sendKeepAliveRequest(socket, port);
|
||||
assert.equal(
|
||||
second,
|
||||
"",
|
||||
"reusing the idle-torn-down socket must get exactly 0 bytes back (the reported bug)"
|
||||
);
|
||||
socket.destroy();
|
||||
}
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
it(
|
||||
"fixed config (getMainServerTimeoutConfig): the same reused connection stays alive past 6.5s idle",
|
||||
{ timeout: 30_000 },
|
||||
async () => {
|
||||
const fixedTimeouts = getMainServerTimeoutConfig({});
|
||||
await withServer(
|
||||
(server) => {
|
||||
server.keepAliveTimeout = fixedTimeouts.keepAliveTimeoutMs;
|
||||
server.headersTimeout = fixedTimeouts.headersTimeoutMs;
|
||||
},
|
||||
async (port) => {
|
||||
const socket = net.connect({ port, host: "127.0.0.1", allowHalfOpen: true });
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
socket.once("connect", () => resolve());
|
||||
socket.once("error", reject);
|
||||
});
|
||||
|
||||
const first = await sendKeepAliveRequest(socket, port);
|
||||
assert.match(first, /200/, "first request on a fresh socket must succeed");
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, IDLE_GAP_MS));
|
||||
|
||||
const second = await sendKeepAliveRequest(socket, port);
|
||||
assert.match(
|
||||
second,
|
||||
/200/,
|
||||
"the reused connection must still get a valid response after the fix"
|
||||
);
|
||||
socket.destroy();
|
||||
}
|
||||
);
|
||||
}
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
// #7003 — the RED/GREEN spec in main-server-keepalive-timeout-7003.test.ts proves
|
||||
// getMainServerTimeoutConfig() raises keepAliveTimeout/headersTimeout above Node's
|
||||
// unconfigured 5_000ms default, and that the original fix wired it into
|
||||
// scripts/dev/run-next.mjs. But run-next.mjs only runs `npm run dev`/`npm start`
|
||||
// from a source checkout. The server real end users run — `omniroute serve`
|
||||
// (npm-installed CLI), Docker, and Electron — spawns the standalone Next build's
|
||||
// server.js via scripts/dev/run-standalone.mjs, which prefers server-ws.mjs
|
||||
// (built from scripts/dev/standalone-server-ws.mjs, copied byte-for-byte into
|
||||
// dist/server-ws.mjs by scripts/build/assembleStandalone.mjs) over the bare
|
||||
// server.js specifically because it wraps `http.createServer` with production
|
||||
// behavior the bare server lacks (peer-IP stamping, method/HEAD guards, WS
|
||||
// proxying, TLS). Before this fix, that wrapper left Node's http.Server
|
||||
// keepAliveTimeout/headersTimeout at their unconfigured defaults, so the
|
||||
// JetBrains AI Assistant reconnect bug reproduced by main-server-keepalive-timeout
|
||||
// -7003.test.ts still hit the production entry point every real user runs.
|
||||
//
|
||||
// standalone-server-ws.mjs has top-level side effects (monkeypatches
|
||||
// http.createServer, generates a random UUID, and unconditionally
|
||||
// `await import("./server.js")` — a file that only exists in the assembled
|
||||
// standalone output, not in the source tree) so it cannot be imported
|
||||
// in-process. Guard the fix by inspecting the source, mirroring the pattern
|
||||
// used for run-next.mjs in run-next-node-env.test.ts.
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
const source = fs.readFileSync(
|
||||
path.resolve(here, "../../scripts/dev/standalone-server-ws.mjs"),
|
||||
"utf8"
|
||||
);
|
||||
|
||||
test("standalone-server-ws.mjs imports getMainServerTimeoutConfig", () => {
|
||||
assert.match(
|
||||
source,
|
||||
/import\s*\{\s*getMainServerTimeoutConfig\s*\}\s*from\s*["'][^"']*runtimeTimeouts(?:\.ts)?["']/,
|
||||
"expected the production server wrapper to import getMainServerTimeoutConfig, " +
|
||||
"the same helper run-next.mjs uses"
|
||||
);
|
||||
});
|
||||
|
||||
test("standalone-server-ws.mjs applies keepAliveTimeout/headersTimeout to the wrapped server", () => {
|
||||
assert.match(
|
||||
source,
|
||||
/server\.keepAliveTimeout\s*=\s*\w*[Tt]imeouts?\.keepAliveTimeoutMs/,
|
||||
"expected the wrapped server object to have keepAliveTimeout set from getMainServerTimeoutConfig()"
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/server\.headersTimeout\s*=\s*\w*[Tt]imeouts?\.headersTimeoutMs/,
|
||||
"expected the wrapped server object to have headersTimeout set from getMainServerTimeoutConfig()"
|
||||
);
|
||||
});
|
||||
|
||||
test("keepAliveTimeout/headersTimeout are applied inside createServerWithResponsesWs, before the server is returned", () => {
|
||||
const factoryIdx = source.search(/function createServerWithResponsesWs/);
|
||||
const keepAliveIdx = source.search(/server\.keepAliveTimeout\s*=/);
|
||||
const returnIdx = source.search(/return server;/);
|
||||
|
||||
assert.ok(factoryIdx !== -1, "expected createServerWithResponsesWs to exist");
|
||||
assert.ok(keepAliveIdx !== -1, "expected a server.keepAliveTimeout assignment to exist");
|
||||
assert.ok(returnIdx !== -1, "expected the wrapped server to be returned");
|
||||
assert.ok(
|
||||
keepAliveIdx > factoryIdx,
|
||||
"timeout wiring must happen inside createServerWithResponsesWs"
|
||||
);
|
||||
assert.ok(
|
||||
keepAliveIdx < returnIdx,
|
||||
"timeout wiring must happen before the server object is returned to the caller"
|
||||
);
|
||||
});
|
||||
8
tsconfig.typecheck-dashboard.json
Normal file
8
tsconfig.typecheck-dashboard.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"noEmit": true,
|
||||
"incremental": false
|
||||
},
|
||||
"include": ["src/app/(dashboard)/**/*.ts", "src/app/(dashboard)/**/*.tsx"]
|
||||
}
|
||||
Reference in New Issue
Block a user