@blaxel/core 0.2.87-preview.167 → 0.2.87-preview.169

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -11,14 +11,21 @@ const sessionsWithListenerBudget = new WeakSet();
11
11
  * When `settings.maxConcurrentH2Requests` is `0`/unset the gate is a no-op
12
12
  * (current default behavior: unlimited concurrency).
13
13
  */
14
- // Safety timeout for a held slot. A request that never resolves (no response,
15
- // no error, no abort) would otherwise hold its slot forever and starve every
16
- // queued request for the same domain. After this window we release the slot
17
- // and let the underlying request continue or reject on its own; we never
18
- // cancel the in-flight request here, we only stop it from blocking the queue.
19
- // Kept deliberately conservative (well above any realistic part-upload time)
20
- // so it does not interfere with legitimately slow but healthy requests.
21
- const H2_SLOT_TIMEOUT_MS = 120_000;
14
+ // Last-resort leak guard for a held slot. The slot is now released on the real
15
+ // stream terminal (end / error / abort / cancel / pre-response reject), which
16
+ // always fires, so this timer is a pure backstop for the one residual case: a
17
+ // request that never opens a stream AND never settles (no response, no error,
18
+ // no abort) it must not pin a slot forever and starve the per-domain queue.
19
+ //
20
+ // When it fires it ONLY frees the queue slot. It NEVER aborts or cancels the
21
+ // underlying request or its response stream: `release` does not touch `req` or
22
+ // the body stream, so a long-lived streaming response keeps flowing. Raised
23
+ // well beyond any legitimate stream lifetime so it cannot interfere with a
24
+ // healthy long-open stream (process.streamLogs / execWithStreaming / port
25
+ // proxy). RESIDUAL: a stream that outlives this backstop would have its slot
26
+ // freed early (the queue could then admit one extra stream); acceptable as a
27
+ // pure backstop, since the slot is otherwise tied to the true stream terminal.
28
+ const H2_SLOT_TIMEOUT_MS = 1_800_000; // 30 minutes
22
29
  const h2GatesByDomain = new Map();
23
30
  function getH2Gate(domain) {
24
31
  let gate = h2GatesByDomain.get(domain);
@@ -29,10 +36,14 @@ function getH2Gate(domain) {
29
36
  return gate;
30
37
  }
31
38
  /**
32
- * Acquire an in-flight slot for `domain`. Resolves with a release function
33
- * that is idempotent and FIFO-fair: releasing wakes the longest-waiting
34
- * queued caller for the same domain. A safety timer also releases the slot
35
- * if the caller never does, preventing per-domain starvation.
39
+ * Acquire an OPEN-STREAM slot for `domain`. Resolves with a release function
40
+ * that is idempotent and FIFO-fair: releasing wakes the longest-waiting queued
41
+ * caller for the same domain. The slot is held for the lifetime of an OPEN H2
42
+ * stream the send path releases it on the stream terminal — so the gate
43
+ * bounds true concurrent streams on the one shared session, not merely requests
44
+ * awaiting headers (ENG-2678). A backstop timer releases the slot if a request
45
+ * neither opens a stream nor ever settles, preventing per-domain starvation;
46
+ * it never aborts the underlying request (see `H2_SLOT_TIMEOUT_MS`).
36
47
  */
37
48
  async function acquireH2Slot(domain) {
38
49
  const max = settings.maxConcurrentH2Requests; // 0/undefined = unlimited
@@ -94,6 +105,12 @@ export function createH2Fetch(session) {
94
105
  */
95
106
  export function createPoolBackedH2Fetch(pool, domain) {
96
107
  return async (input) => {
108
+ // Acquire the slot here, but hand its release to the send path: it is held
109
+ // for the OPEN-STREAM lifetime and freed on the stream terminal. Paths that
110
+ // never open a stream on the shared session (fetch fallbacks go over a
111
+ // different connection) release it immediately so they do not count against
112
+ // the open-stream budget. `rel` is idempotent, so the explicit releases
113
+ // below are safe even though the send path may also release.
97
114
  const rel = await acquireH2Slot(domain);
98
115
  try {
99
116
  const session = await pool.get(domain);
@@ -104,6 +121,7 @@ export function createPoolBackedH2Fetch(pool, domain) {
104
121
  onH2RequestCreated: () => {
105
122
  h2RequestCreated = true;
106
123
  },
124
+ releaseSlot: rel,
107
125
  });
108
126
  }
109
127
  catch (err) {
@@ -113,14 +131,23 @@ export function createPoolBackedH2Fetch(pool, domain) {
113
131
  throw err;
114
132
  }
115
133
  }
134
+ // No usable session: this fetch does not open a stream on the shared
135
+ // session, so free the slot before falling back.
136
+ rel();
116
137
  return await globalThis.fetch(input);
117
138
  }
118
- finally {
139
+ catch (err) {
140
+ // Pre-send throw (e.g. pool.get() or Request body read): release the slot
141
+ // so a failed request never pins it. Idempotent if already released.
119
142
  rel();
143
+ throw err;
120
144
  }
121
145
  };
122
146
  }
123
147
  export async function h2RequestDirectFromPool(pool, domain, url, init) {
148
+ // See createPoolBackedH2Fetch: the slot is held for the OPEN-STREAM lifetime
149
+ // and released by the send path on the stream terminal; non-stream fallbacks
150
+ // release it immediately. `rel` is idempotent.
124
151
  const rel = await acquireH2Slot(domain);
125
152
  try {
126
153
  const session = await pool.get(domain);
@@ -131,6 +158,7 @@ export async function h2RequestDirectFromPool(pool, domain, url, init) {
131
158
  onH2RequestCreated: () => {
132
159
  h2RequestCreated = true;
133
160
  },
161
+ releaseSlot: rel,
134
162
  });
135
163
  }
136
164
  catch (err) {
@@ -140,10 +168,13 @@ export async function h2RequestDirectFromPool(pool, domain, url, init) {
140
168
  throw err;
141
169
  }
142
170
  }
171
+ // No usable session: fetch over a different connection, no stream opened.
172
+ rel();
143
173
  return await globalThis.fetch(url, init);
144
174
  }
145
- finally {
175
+ catch (err) {
146
176
  rel();
177
+ throw err;
147
178
  }
148
179
  }
149
180
  /**
@@ -155,6 +186,9 @@ export function h2RequestDirect(session, url, init) {
155
186
  }
156
187
  function h2RequestDirectInternal(session, url, init, options) {
157
188
  if (session.closed || session.destroyed) {
189
+ // Pre-flight fallback (session unusable): no stream opens on the shared
190
+ // session, so free any held slot before going over globalThis.fetch.
191
+ options?.releaseSlot?.();
158
192
  return globalThis.fetch(url, init);
159
193
  }
160
194
  const parsed = new URL(url);
@@ -194,7 +228,9 @@ function h2RequestDirectInternal(session, url, init, options) {
194
228
  else {
195
229
  // FormData, ReadableStream, Blob, etc. can't be serialized to Buffer
196
230
  // for manual H2 framing — fall back to regular fetch (pre-flight,
197
- // nothing has been sent on the wire yet).
231
+ // nothing has been sent on the wire yet). No stream opens on the shared
232
+ // session, so free any held slot before falling back.
233
+ options?.releaseSlot?.();
198
234
  return globalThis.fetch(url, init);
199
235
  }
200
236
  if (!h2Headers["content-length"]) {
@@ -238,13 +274,19 @@ function _h2Send(session, h2Headers, body, signal, fallbackUrl, fallbackInit, op
238
274
  let streamClosed = false;
239
275
  let req = null;
240
276
  let releaseSessionRef = () => { };
277
+ // The per-domain open-stream slot (idempotent; no-op for the non-pool
278
+ // transports). Held for the OPEN-STREAM lifetime and released alongside the
279
+ // session ref from cleanupActiveRequest() on every terminal path.
280
+ const releaseSlot = options?.releaseSlot ?? (() => { });
241
281
  let abort = null;
242
282
  try {
243
283
  req = session.request(h2Headers);
244
284
  }
245
285
  catch {
246
286
  // Pre-flight fallback: session.request() threw synchronously, so no
247
- // H2 frames were sent. Safe to retry over globalThis.fetch.
287
+ // H2 frames were sent. No stream opened on the shared session, so free
288
+ // the slot before retrying over globalThis.fetch.
289
+ releaseSlot();
248
290
  globalThis.fetch(fallbackUrl, fallbackInit).then(resolve, reject);
249
291
  return;
250
292
  }
@@ -259,7 +301,13 @@ function _h2Send(session, h2Headers, body, signal, fallbackUrl, fallbackInit, op
259
301
  const cleanupActiveRequest = () => {
260
302
  if (abort)
261
303
  signal?.removeEventListener("abort", abort);
304
+ // Slot and session-ref share the OPEN-STREAM lifetime but stay
305
+ // independent and each idempotent (PM-2160). Every terminal path funnels
306
+ // through here exactly once: pre-response reject (close/goaway/error),
307
+ // abort-before-response, abort-during-stream, stream end, stream error,
308
+ // and ReadableStream cancel — so the slot is freed once on each.
262
309
  releaseSessionRef();
310
+ releaseSlot();
263
311
  };
264
312
  const rejectBeforeResponse = (err) => {
265
313
  if (settled)
@@ -117,6 +117,15 @@ export class H2Pool {
117
117
  return session;
118
118
  }
119
119
  if (await this.ping(session)) {
120
+ // ENG-2676 generation/identity pin: `await this.ping` yields, and during
121
+ // that await an eviction listener (goaway/error/close ->
122
+ // attachEvictionListeners, see above) may have deleted or replaced this
123
+ // entry. `entry` is the exact object held in the map, so if it is no
124
+ // longer the cached generation, refuse the now-stale session instead of
125
+ // handing back a zombie — the ENG-2422 failure re-entering through the
126
+ // validate race. The caller falls through to establish a fresh session.
127
+ if (this.sessions.get(domain) !== entry)
128
+ return null;
120
129
  this.markUsed(domain, session);
121
130
  return session;
122
131
  }
@@ -5,8 +5,8 @@ import { authentication } from "../authentication/index.js";
5
5
  import { env } from "../common/env.js";
6
6
  import { fs, os, path } from "../common/node.js";
7
7
  // Build info - these placeholders are replaced at build time by build:replace-imports
8
- const BUILD_VERSION = "0.2.87-preview.167";
9
- const BUILD_COMMIT = "deb13a8059a0b1841c255ea2f5fb5461304d32ef";
8
+ const BUILD_VERSION = "0.2.87-preview.169";
9
+ const BUILD_COMMIT = "5357b12d98548db2b306537be7d96d2888ac8cb1";
10
10
  const BUILD_SENTRY_DSN = "https://fd5e60e1c9820e1eef5ccebb84a07127@o4508714045276160.ingest.us.sentry.io/4510465864564736";
11
11
  const BLAXEL_API_VERSION = "2026-04-16";
12
12
  // Cache for config.yaml tracking value