From 4f52685e2524de5bbeb5dd20caebade0a616f645 Mon Sep 17 00:00:00 2001 From: sametbasbug Date: Tue, 25 Aug 2026 01:07:37 +0300 Subject: [PATCH 1/2] =?UTF-8?q?Test=20fetch'lerinde=20keep-alive=20ba?= =?UTF-8?q?=C4=9Flant=C4=B1s=C4=B1n=C4=B1=20yeniden=20kullanma?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI'da ara sıra slice3'ün akış testi hiçbir iddiaya varmadan 3-4 ms içinde düşüyordu: UND_ERR_SOCKET "other side closed", bytesRead 1631. Sıfır olmayan bytesRead soketin daha önce bir yanıt taşıdığını söylüyor — yani hata yeni kurulan bir bağlantıda değil, undici'nin havuzundan gelen keep-alive bağlantısında. Sunucu onu kapatmış, istemci ölü sokete yazmış. İstemcinin kapanmayı görememesinin sebebi bu dosyaların çalışma biçimi: worker ayaktayken spawnSync ile ikinci wrangler süreçleri koşuyor ve spawnSync olay döngüsünü saniyelerce tamamen durduruyor. O sürede undici ne FIN'i işleyebiliyor ne de kendi 4 saniyelik boşta kalma zamanlayıcısını çalıştırıp soketi havuzdan atabiliyor. Sunucu tarafında da güvence yok: workerd yanıtta Keep-Alive başlığı göndermiyor, boşta bir bağlantıyı ne zaman kapatacağını hiç söylemiyor. HTTP/1.1'de yazmadan önce bağlantının açık olup olmadığını bilmenin yolu yok. Normal istemciler bunu tekrar denemeyle örter; testte hatayı örtmek istemiyoruz. Kalan sağlam seçenek bağlantıyı hiç yeniden kullanmamak. Agent({ pipelining: 0 }) keep-alive'ı kapatıyor, her istek kendi bağlantısını açıyor. Ölçüm (orbit-slice3-tests, aynı 63 istek): - önce: 2 bağlantı, 55 yeniden kullanım - sonra: 57 bağlantı, 0 yeniden kullanım Mekanizmanın kendisi ayrı bir deneyle doğrulandı: aynı Node/undici ile, sunucu boştaki bağlantıyı kapatırken spawnSync ile bloke edilmiş bir döngüde düz fetch 8 turun 4'ünde düştü, pipelining: 0 ile 8/8 geçti. Gerçek test dosyasını yerelde istekle düşüremedim; kalan koşul FIN'in son poll ile yazma arasındaki aralığa denk gelmesi ve bunu zamanlayamıyorum. undici zaten overrides'ta pinlenmişti; doğrudan içe aktardığımız için artık devDependencies'te de açıkça duruyor. Co-Authored-By: Claude Opus 5 --- package-lock.json | 1 + package.json | 1 + scripts/orbit-mcp-authorization-tests.ts | 5 +++ scripts/orbit-site-authorization-tests.ts | 5 +++ .../orbit-site-client-registration-tests.ts | 5 +++ scripts/orbit-site-signin-e2e-tests.ts | 5 +++ scripts/orbit-slice1-tests.ts | 5 +++ scripts/orbit-slice3-tests.ts | 5 +++ scripts/orbit-slice4-tests.ts | 5 +++ scripts/orbit-slice5-tests.ts | 5 +++ scripts/support/d1-test-worker-harness.ts | 5 +++ scripts/support/test-http.ts | 44 +++++++++++++++++++ 12 files changed, 91 insertions(+) create mode 100644 scripts/support/test-http.ts diff --git a/package-lock.json b/package-lock.json index f622e3b..1ef382a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,6 +25,7 @@ "sharp": "^0.35.3", "tsx": "4.23.12", "typescript": "6.0.3", + "undici": "^7.29.0", "wrangler": "4.125.0" } }, diff --git a/package.json b/package.json index 099f86a..dccabba 100644 --- a/package.json +++ b/package.json @@ -77,6 +77,7 @@ "sharp": "^0.35.3", "tsx": "4.23.12", "typescript": "6.0.3", + "undici": "^7.29.0", "wrangler": "4.125.0" }, "overrides": { diff --git a/scripts/orbit-mcp-authorization-tests.ts b/scripts/orbit-mcp-authorization-tests.ts index 73dddff..4bbee05 100644 --- a/scripts/orbit-mcp-authorization-tests.ts +++ b/scripts/orbit-mcp-authorization-tests.ts @@ -5,6 +5,7 @@ import { tmpdir } from 'node:os'; import path from 'node:path'; import { after, before, describe, test } from 'node:test'; import { reserveWorkerPorts } from './orbit-test-ports'; +import { useFreshConnectionPerRequest } from './support/test-http'; import { BACKUP_SCHEMA_VERSION } from '../src/server/backup/dynamic-backup'; import { createOpaqueToken, @@ -12,6 +13,10 @@ import { verifyOpaqueToken, } from '../src/server/identity/tokens'; +/* Havuzda bekleyen bir keep-alive soketi, bu dosyanın spawnSync + * bloklarından sağ çıkmıyor; gerekçesi support/test-http.ts içinde. */ +useFreshConnectionPerRequest(); + const ROOT = process.cwd(); const WRANGLER = path.join(ROOT, 'node_modules', 'wrangler', 'bin', 'wrangler.js'); const CONFIG = 'wrangler.test.jsonc'; diff --git a/scripts/orbit-site-authorization-tests.ts b/scripts/orbit-site-authorization-tests.ts index 7aaecb6..fd59c07 100644 --- a/scripts/orbit-site-authorization-tests.ts +++ b/scripts/orbit-site-authorization-tests.ts @@ -5,6 +5,7 @@ import { tmpdir } from 'node:os'; import path from 'node:path'; import { after, before, describe, test } from 'node:test'; import { reserveWorkerPorts } from './orbit-test-ports'; +import { useFreshConnectionPerRequest } from './support/test-http'; import { normalizeSiteAuthorizationScopes, parseSiteAuthorizationScopes, @@ -30,6 +31,10 @@ import { } from '../src/server/identity/site-authorization-request'; import { siteConsentPage } from '../src/server/http/site-consent-page'; +/* Havuzda bekleyen bir keep-alive soketi, bu dosyanın spawnSync + * bloklarından sağ çıkmıyor; gerekçesi support/test-http.ts içinde. */ +useFreshConnectionPerRequest(); + const ROOT = process.cwd(); const WRANGLER = path.join(ROOT, 'node_modules', 'wrangler', 'bin', 'wrangler.js'); const CONFIG = 'wrangler.test.jsonc'; diff --git a/scripts/orbit-site-client-registration-tests.ts b/scripts/orbit-site-client-registration-tests.ts index 3c5c480..2c16353 100644 --- a/scripts/orbit-site-client-registration-tests.ts +++ b/scripts/orbit-site-client-registration-tests.ts @@ -21,8 +21,13 @@ import path from 'node:path'; import { after, before, describe, test } from 'node:test'; import { reserveWorkerPorts } from './orbit-test-ports'; +import { useFreshConnectionPerRequest } from './support/test-http'; import { createOpaqueToken, hmacDigest, randomBase64Url } from '../src/server/identity/tokens'; +/* Havuzda bekleyen bir keep-alive soketi, bu dosyanın spawnSync + * bloklarından sağ çıkmıyor; gerekçesi support/test-http.ts içinde. */ +useFreshConnectionPerRequest(); + const ROOT = process.cwd(); const WRANGLER = path.join(ROOT, 'node_modules', 'wrangler', 'bin', 'wrangler.js'); const CONFIG = 'wrangler.slice1-test.jsonc'; diff --git a/scripts/orbit-site-signin-e2e-tests.ts b/scripts/orbit-site-signin-e2e-tests.ts index 51c721c..64fe7a9 100644 --- a/scripts/orbit-site-signin-e2e-tests.ts +++ b/scripts/orbit-site-signin-e2e-tests.ts @@ -18,6 +18,7 @@ import { tmpdir } from 'node:os'; import path from 'node:path'; import { after, before, describe, test } from 'node:test'; import { reserveWorkerPorts } from './orbit-test-ports'; +import { useFreshConnectionPerRequest } from './support/test-http'; import { createOpaqueToken, hmacDigest, @@ -25,6 +26,10 @@ import { sha256Base64Url, } from '../src/server/identity/tokens'; +/* Havuzda bekleyen bir keep-alive soketi, bu dosyanın spawnSync + * bloklarından sağ çıkmıyor; gerekçesi support/test-http.ts içinde. */ +useFreshConnectionPerRequest(); + const ROOT = process.cwd(); const WRANGLER = path.join(ROOT, 'node_modules', 'wrangler', 'bin', 'wrangler.js'); const CONFIG = 'wrangler.slice1-test.jsonc'; diff --git a/scripts/orbit-slice1-tests.ts b/scripts/orbit-slice1-tests.ts index 5450175..9cead3d 100644 --- a/scripts/orbit-slice1-tests.ts +++ b/scripts/orbit-slice1-tests.ts @@ -5,6 +5,7 @@ import { tmpdir } from 'node:os'; import path from 'node:path'; import { after, before, describe, test } from 'node:test'; import { reserveWorkerPorts } from './orbit-test-ports'; +import { useFreshConnectionPerRequest } from './support/test-http'; import { CSRF_COOKIE, CSRF_HEADER, @@ -21,6 +22,10 @@ import { } from '../src/server/identity/tokens'; import { LEGAL_LAST_UPDATED } from '../src/data/legal'; +/* Havuzda bekleyen bir keep-alive soketi, bu dosyanın spawnSync + * bloklarından sağ çıkmıyor; gerekçesi support/test-http.ts içinde. */ +useFreshConnectionPerRequest(); + const ROOT = process.cwd(); const WRANGLER = path.join(ROOT, 'node_modules', 'wrangler', 'bin', 'wrangler.js'); const CONFIG = 'wrangler.slice1-test.jsonc'; diff --git a/scripts/orbit-slice3-tests.ts b/scripts/orbit-slice3-tests.ts index 116e6fe..a0b4a4a 100644 --- a/scripts/orbit-slice3-tests.ts +++ b/scripts/orbit-slice3-tests.ts @@ -5,8 +5,13 @@ import { tmpdir } from 'node:os'; import path from 'node:path'; import { after, before, describe, test } from 'node:test'; import { reserveWorkerPorts } from './orbit-test-ports'; +import { useFreshConnectionPerRequest } from './support/test-http'; import { loadManifest, verifyManifest } from './orbit-slice3-manifest'; +/* Havuzda bekleyen bir keep-alive soketi, bu dosyanın spawnSync + * bloklarından sağ çıkmıyor; gerekçesi support/test-http.ts içinde. */ +useFreshConnectionPerRequest(); + const ROOT = process.cwd(); const WRANGLER = path.join(ROOT, 'node_modules', 'wrangler', 'bin', 'wrangler.js'); const CONFIG = 'wrangler.slice1-test.jsonc'; diff --git a/scripts/orbit-slice4-tests.ts b/scripts/orbit-slice4-tests.ts index b734898..337a591 100644 --- a/scripts/orbit-slice4-tests.ts +++ b/scripts/orbit-slice4-tests.ts @@ -5,6 +5,7 @@ import { mkdtemp, readdir, readFile, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { after, before, describe, test } from 'node:test'; import { reserveWorkerPorts } from './orbit-test-ports'; +import { useFreshConnectionPerRequest } from './support/test-http'; import { createEntityId } from '../src/server/foundation/ids'; import { createOpaqueToken, hmacDigest, randomBase64Url, sha256Base64Url } from '../src/server/identity/tokens'; import { canonicalJson } from '../src/server/publication/content'; @@ -15,6 +16,10 @@ import { type DynamicBackup, } from '../src/server/backup/dynamic-backup'; +/* Havuzda bekleyen bir keep-alive soketi, bu dosyanın spawnSync + * bloklarından sağ çıkmıyor; gerekçesi support/test-http.ts içinde. */ +useFreshConnectionPerRequest(); + const ROOT = process.cwd(); const WRANGLER = path.join(ROOT, 'node_modules', 'wrangler', 'bin', 'wrangler.js'); const TSX = path.join(ROOT, 'node_modules', 'tsx', 'dist', 'cli.mjs'); diff --git a/scripts/orbit-slice5-tests.ts b/scripts/orbit-slice5-tests.ts index acd6bda..8c4e4b5 100644 --- a/scripts/orbit-slice5-tests.ts +++ b/scripts/orbit-slice5-tests.ts @@ -5,6 +5,7 @@ import { mkdtemp, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { after, before, describe, test } from 'node:test'; import { reserveWorkerPorts } from './orbit-test-ports'; +import { useFreshConnectionPerRequest } from './support/test-http'; import { createEntityId } from '../src/server/foundation/ids'; import { createOpaqueToken, hmacDigest, randomBase64Url, sha256Base64Url } from '../src/server/identity/tokens'; import { canonicalJson } from '../src/server/publication/content'; @@ -17,6 +18,10 @@ import { verifyDynamicBackup } from '../src/server/backup/dynamic-backup'; import { ImageTransformError, inspectImage, transformImage } from '../src/server/media/image-processor'; import sharp from 'sharp'; +/* Havuzda bekleyen bir keep-alive soketi, bu dosyanın spawnSync + * bloklarından sağ çıkmıyor; gerekçesi support/test-http.ts içinde. */ +useFreshConnectionPerRequest(); + const ROOT = process.cwd(); const WRANGLER = path.join(ROOT, 'node_modules', 'wrangler', 'bin', 'wrangler.js'); const TSX = path.join(ROOT, 'node_modules', 'tsx', 'dist', 'cli.mjs'); diff --git a/scripts/support/d1-test-worker-harness.ts b/scripts/support/d1-test-worker-harness.ts index d0a835b..2e6860b 100644 --- a/scripts/support/d1-test-worker-harness.ts +++ b/scripts/support/d1-test-worker-harness.ts @@ -11,6 +11,11 @@ import { mkdtemp, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import path from 'node:path'; import { reserveWorkerPorts } from '../orbit-test-ports'; +import { useFreshConnectionPerRequest } from './test-http'; + +/* Havuzda bekleyen bir keep-alive soketi, bu dosyanın spawnSync + * bloklarından sağ çıkmıyor; gerekçesi support/test-http.ts içinde. */ +useFreshConnectionPerRequest(); const ROOT = process.cwd(); const WRANGLER = path.join(ROOT, 'node_modules', 'wrangler', 'bin', 'wrangler.js'); diff --git a/scripts/support/test-http.ts b/scripts/support/test-http.ts new file mode 100644 index 0000000..5272f1c --- /dev/null +++ b/scripts/support/test-http.ts @@ -0,0 +1,44 @@ +/** + * Yerel test worker'larına giden fetch'lerin bağlantı politikası. + * + * Belirti şuydu: CI'da ara sıra, bir testin ilk `fetch`'i hiçbir iddiaya + * varmadan 3-4 ms içinde düşüyordu. + * + * TypeError: fetch failed + * cause: SocketError: other side closed (UND_ERR_SOCKET) + * socket: { bytesWritten: 370, bytesRead: 1631 } + * + * `bytesRead` sıfır değil: soket daha önce bir yanıt taşımış, yani bu yeni + * kurulan bir bağlantı değil, undici'nin havuzundan gelen bir keep-alive + * bağlantısı. Sunucu onu kapatmış (FIN), istemci ölü sokete yazmış. + * + * Neden istemci FIN'i görmüyor: bu test dosyaları worker ayaktayken + * `spawnSync` ile ikinci wrangler süreçleri koşturuyor (migration, importer, + * d1 execute). `spawnSync` olay döngüsünü saniyelerce tamamen durduruyor. + * O sürede undici ne soketin FIN'ini işleyebiliyor ne de kendi boşta kalma + * zamanlayıcısını (varsayılan 4 sn) çalıştırıp soketi havuzdan atabiliyor. + * `spawnSync` döner dönmez gelen `fetch` isteği aynı senkron adımda ölü + * sokete yazılıyor. Sunucu tarafında da bir güvence yok: workerd yanıtta + * `Keep-Alive` başlığı göndermiyor, yani boşta kalan bir bağlantıyı ne + * zaman kapatacağını istemciye hiç söylemiyor. + * + * HTTP/1.1'de bunun sağlıklı bir çözümü yok: yazmadan önce bağlantının + * hâlâ açık olup olmadığını bilmenin yolu yoktur. Normal istemciler bunu + * "güvenli isteği bir kez tekrar dene" ile örter; testte hatayı örtmek + * istemiyoruz. Kalan tek sağlam seçenek bağlantıyı hiç yeniden + * kullanmamak: `pipelining: 0` undici'de keep-alive'ı kapatır, her istek + * kendi bağlantısını açar ve yarış ortadan kalkar. Yerelde birkaç düzine + * istek için maliyeti ölçülemeyecek kadar küçük. + */ +import { Agent, setGlobalDispatcher } from 'undici'; + +let installed = false; + +/* Node'un global `fetch`'i dispatcher'ı `Symbol.for('undici.globalDispatcher.1')` + * üzerinden okuyor; undici paketinin `setGlobalDispatcher`'ı aynı sembole + * yazdığı için yerleşik fetch de bu ayarı görüyor. */ +export function useFreshConnectionPerRequest(): void { + if (installed) return; + setGlobalDispatcher(new Agent({ pipelining: 0 })); + installed = true; +} From 2b77d209ff755d8ae0563c7ee68bf218946a06d8 Mon Sep 17 00:00:00 2001 From: sametbasbug Date: Tue, 25 Aug 2026 01:14:35 +0300 Subject: [PATCH 2/2] =?UTF-8?q?Ba=C4=9Flant=C4=B1=20politikas=C4=B1n=C4=B1?= =?UTF-8?q?n=20sessizce=20d=C3=BC=C5=9Fmesine=20kar=C5=9F=C4=B1=20bek?= =?UTF-8?q?=C3=A7i=20ekle?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit useFreshConnectionPerRequest global bir sembole yazıp Node'un yerleşik fetch'inin onu okumasına güveniyordu. Bugün doğru çalışıyor: Node 26 / undici 7'de undici.globalDispatcher.1 ve .2 aynı nesneyi gösteriyor. Ama bu bağ koparsa çağrı sessizce hiçbir şey yapmaz. Keep-alive geri gelir, UND_ERR_SOCKET yarışı yeniden açılır ve hiçbir test kırılmaz — çünkü geri gelen şey ara sıra düşen bir yarış. Yani düzeltme, kendi bozulduğunu haber veremeyen bir düzeltmeydi. Bekçi ayarın yerleştiğini kurulum anında doğruluyor; yerleşmediyse testler ilk satırda duruyor. Doğrulama: setGlobalDispatcher devre dışı bırakılarak mutasyon sınandı, bekçi ateşledi; mutasyon geri alındıktan sonra sessiz. npm run check (213 dosya, 0 hata) ve npm run test:d1 (341/341) çıkış kodu 0. Bağlantı ölçümü: 57 istek / 57 bağlantı / 0 yeniden kullanım. Co-Authored-By: Claude Opus 5 --- scripts/support/test-http.ts | 37 +++++++++++++++++++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/scripts/support/test-http.ts b/scripts/support/test-http.ts index 5272f1c..eb77f17 100644 --- a/scripts/support/test-http.ts +++ b/scripts/support/test-http.ts @@ -31,6 +31,7 @@ * istek için maliyeti ölçülemeyecek kadar küçük. */ import { Agent, setGlobalDispatcher } from 'undici'; +import type { Dispatcher } from 'undici'; let installed = false; @@ -39,6 +40,40 @@ let installed = false; * yazdığı için yerleşik fetch de bu ayarı görüyor. */ export function useFreshConnectionPerRequest(): void { if (installed) return; - setGlobalDispatcher(new Agent({ pipelining: 0 })); + const agent = new Agent({ pipelining: 0 }); + setGlobalDispatcher(agent); + assertDispatcherInstalled(agent); installed = true; } + +/* Bekçi: ayarın gerçekten yerleşip yerleşmediğini kontrol eder. + * + * `setGlobalDispatcher` global bir sembole yazıyor ve Node'un yerleşik + * `fetch`'i onu okuyor. Bugün (Node 26 / undici 7) iki sembol de — + * `undici.globalDispatcher.1` ve `.2` — aynı nesneyi gösteriyor. Yarın + * paket ile Node'un sembolleri ayrışırsa çağrı sessizce hiçbir şey yapmaz: + * keep-alive geri gelir, flake geri gelir ve hiçbir test kırılmaz, çünkü + * bu yalnızca ara sıra düşen bir yarışı geri açar. + * + * Sessiz bozulmayı gürültülü hataya çeviriyoruz: yerleşmediyse testler + * daha ilk satırda dursun. */ +function assertDispatcherInstalled(expected: Dispatcher): void { + const seen = [ + Symbol.for('undici.globalDispatcher.1'), + Symbol.for('undici.globalDispatcher.2'), + ].filter((symbol) => symbol in globalThis); + + const missed = seen.filter( + (symbol) => (globalThis as Record)[symbol] !== expected, + ); + + if (seen.length === 0 || missed.length > 0) { + throw new Error( + 'Test fetch bağlantı politikası yerleşmedi: undici setGlobalDispatcher ' + + "Node'un yerleşik fetch'inin okuduğu sembole yazmıyor. Keep-alive " + + 'yeniden kullanımı geri döner ve UND_ERR_SOCKET flake\'i yeniden ' + + 'başlar. support/test-http.ts içindeki gerekçeye bak; undici ve Node ' + + 'sürümlerinin uyumunu kontrol et.', + ); + } +}