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

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.
@@ -17,14 +17,21 @@ const sessionsWithListenerBudget = new WeakSet();
17
17
  * When `settings.maxConcurrentH2Requests` is `0`/unset the gate is a no-op
18
18
  * (current default behavior: unlimited concurrency).
19
19
  */
20
- // Safety timeout for a held slot. A request that never resolves (no response,
21
- // no error, no abort) would otherwise hold its slot forever and starve every
22
- // queued request for the same domain. After this window we release the slot
23
- // and let the underlying request continue or reject on its own; we never
24
- // cancel the in-flight request here, we only stop it from blocking the queue.
25
- // Kept deliberately conservative (well above any realistic part-upload time)
26
- // so it does not interfere with legitimately slow but healthy requests.
27
- const H2_SLOT_TIMEOUT_MS = 120_000;
20
+ // Last-resort leak guard for a held slot. The slot is now released on the real
21
+ // stream terminal (end / error / abort / cancel / pre-response reject), which
22
+ // always fires, so this timer is a pure backstop for the one residual case: a
23
+ // request that never opens a stream AND never settles (no response, no error,
24
+ // no abort) it must not pin a slot forever and starve the per-domain queue.
25
+ //
26
+ // When it fires it ONLY frees the queue slot. It NEVER aborts or cancels the
27
+ // underlying request or its response stream: `release` does not touch `req` or
28
+ // the body stream, so a long-lived streaming response keeps flowing. Raised
29
+ // well beyond any legitimate stream lifetime so it cannot interfere with a
30
+ // healthy long-open stream (process.streamLogs / execWithStreaming / port
31
+ // proxy). RESIDUAL: a stream that outlives this backstop would have its slot
32
+ // freed early (the queue could then admit one extra stream); acceptable as a
33
+ // pure backstop, since the slot is otherwise tied to the true stream terminal.
34
+ const H2_SLOT_TIMEOUT_MS = 1_800_000; // 30 minutes
28
35
  const h2GatesByDomain = new Map();
29
36
  function getH2Gate(domain) {
30
37
  let gate = h2GatesByDomain.get(domain);
@@ -35,10 +42,14 @@ function getH2Gate(domain) {
35
42
  return gate;
36
43
  }
37
44
  /**
38
- * Acquire an in-flight slot for `domain`. Resolves with a release function
39
- * that is idempotent and FIFO-fair: releasing wakes the longest-waiting
40
- * queued caller for the same domain. A safety timer also releases the slot
41
- * if the caller never does, preventing per-domain starvation.
45
+ * Acquire an OPEN-STREAM slot for `domain`. Resolves with a release function
46
+ * that is idempotent and FIFO-fair: releasing wakes the longest-waiting queued
47
+ * caller for the same domain. The slot is held for the lifetime of an OPEN H2
48
+ * stream the send path releases it on the stream terminal — so the gate
49
+ * bounds true concurrent streams on the one shared session, not merely requests
50
+ * awaiting headers (ENG-2678). A backstop timer releases the slot if a request
51
+ * neither opens a stream nor ever settles, preventing per-domain starvation;
52
+ * it never aborts the underlying request (see `H2_SLOT_TIMEOUT_MS`).
42
53
  */
43
54
  async function acquireH2Slot(domain) {
44
55
  const max = settings_js_1.settings.maxConcurrentH2Requests; // 0/undefined = unlimited
@@ -100,6 +111,12 @@ function createH2Fetch(session) {
100
111
  */
101
112
  function createPoolBackedH2Fetch(pool, domain) {
102
113
  return async (input) => {
114
+ // Acquire the slot here, but hand its release to the send path: it is held
115
+ // for the OPEN-STREAM lifetime and freed on the stream terminal. Paths that
116
+ // never open a stream on the shared session (fetch fallbacks go over a
117
+ // different connection) release it immediately so they do not count against
118
+ // the open-stream budget. `rel` is idempotent, so the explicit releases
119
+ // below are safe even though the send path may also release.
103
120
  const rel = await acquireH2Slot(domain);
104
121
  try {
105
122
  const session = await pool.get(domain);
@@ -110,6 +127,7 @@ function createPoolBackedH2Fetch(pool, domain) {
110
127
  onH2RequestCreated: () => {
111
128
  h2RequestCreated = true;
112
129
  },
130
+ releaseSlot: rel,
113
131
  });
114
132
  }
115
133
  catch (err) {
@@ -119,14 +137,23 @@ function createPoolBackedH2Fetch(pool, domain) {
119
137
  throw err;
120
138
  }
121
139
  }
140
+ // No usable session: this fetch does not open a stream on the shared
141
+ // session, so free the slot before falling back.
142
+ rel();
122
143
  return await globalThis.fetch(input);
123
144
  }
124
- finally {
145
+ catch (err) {
146
+ // Pre-send throw (e.g. pool.get() or Request body read): release the slot
147
+ // so a failed request never pins it. Idempotent if already released.
125
148
  rel();
149
+ throw err;
126
150
  }
127
151
  };
128
152
  }
129
153
  async function h2RequestDirectFromPool(pool, domain, url, init) {
154
+ // See createPoolBackedH2Fetch: the slot is held for the OPEN-STREAM lifetime
155
+ // and released by the send path on the stream terminal; non-stream fallbacks
156
+ // release it immediately. `rel` is idempotent.
130
157
  const rel = await acquireH2Slot(domain);
131
158
  try {
132
159
  const session = await pool.get(domain);
@@ -137,6 +164,7 @@ async function h2RequestDirectFromPool(pool, domain, url, init) {
137
164
  onH2RequestCreated: () => {
138
165
  h2RequestCreated = true;
139
166
  },
167
+ releaseSlot: rel,
140
168
  });
141
169
  }
142
170
  catch (err) {
@@ -146,10 +174,13 @@ async function h2RequestDirectFromPool(pool, domain, url, init) {
146
174
  throw err;
147
175
  }
148
176
  }
177
+ // No usable session: fetch over a different connection, no stream opened.
178
+ rel();
149
179
  return await globalThis.fetch(url, init);
150
180
  }
151
- finally {
181
+ catch (err) {
152
182
  rel();
183
+ throw err;
153
184
  }
154
185
  }
155
186
  /**
@@ -161,6 +192,9 @@ function h2RequestDirect(session, url, init) {
161
192
  }
162
193
  function h2RequestDirectInternal(session, url, init, options) {
163
194
  if (session.closed || session.destroyed) {
195
+ // Pre-flight fallback (session unusable): no stream opens on the shared
196
+ // session, so free any held slot before going over globalThis.fetch.
197
+ options?.releaseSlot?.();
164
198
  return globalThis.fetch(url, init);
165
199
  }
166
200
  const parsed = new URL(url);
@@ -200,7 +234,9 @@ function h2RequestDirectInternal(session, url, init, options) {
200
234
  else {
201
235
  // FormData, ReadableStream, Blob, etc. can't be serialized to Buffer
202
236
  // for manual H2 framing — fall back to regular fetch (pre-flight,
203
- // nothing has been sent on the wire yet).
237
+ // nothing has been sent on the wire yet). No stream opens on the shared
238
+ // session, so free any held slot before falling back.
239
+ options?.releaseSlot?.();
204
240
  return globalThis.fetch(url, init);
205
241
  }
206
242
  if (!h2Headers["content-length"]) {
@@ -244,13 +280,19 @@ function _h2Send(session, h2Headers, body, signal, fallbackUrl, fallbackInit, op
244
280
  let streamClosed = false;
245
281
  let req = null;
246
282
  let releaseSessionRef = () => { };
283
+ // The per-domain open-stream slot (idempotent; no-op for the non-pool
284
+ // transports). Held for the OPEN-STREAM lifetime and released alongside the
285
+ // session ref from cleanupActiveRequest() on every terminal path.
286
+ const releaseSlot = options?.releaseSlot ?? (() => { });
247
287
  let abort = null;
248
288
  try {
249
289
  req = session.request(h2Headers);
250
290
  }
251
291
  catch {
252
292
  // Pre-flight fallback: session.request() threw synchronously, so no
253
- // H2 frames were sent. Safe to retry over globalThis.fetch.
293
+ // H2 frames were sent. No stream opened on the shared session, so free
294
+ // the slot before retrying over globalThis.fetch.
295
+ releaseSlot();
254
296
  globalThis.fetch(fallbackUrl, fallbackInit).then(resolve, reject);
255
297
  return;
256
298
  }
@@ -265,7 +307,13 @@ function _h2Send(session, h2Headers, body, signal, fallbackUrl, fallbackInit, op
265
307
  const cleanupActiveRequest = () => {
266
308
  if (abort)
267
309
  signal?.removeEventListener("abort", abort);
310
+ // Slot and session-ref share the OPEN-STREAM lifetime but stay
311
+ // independent and each idempotent (PM-2160). Every terminal path funnels
312
+ // through here exactly once: pre-response reject (close/goaway/error),
313
+ // abort-before-response, abort-during-stream, stream end, stream error,
314
+ // and ReadableStream cancel — so the slot is freed once on each.
268
315
  releaseSessionRef();
316
+ releaseSlot();
269
317
  };
270
318
  const rejectBeforeResponse = (err) => {
271
319
  if (settled)
@@ -11,8 +11,8 @@ const index_js_1 = require("../authentication/index.js");
11
11
  const env_js_1 = require("../common/env.js");
12
12
  const node_js_1 = require("../common/node.js");
13
13
  // Build info - these placeholders are replaced at build time by build:replace-imports
14
- const BUILD_VERSION = "0.2.87-preview.167";
15
- const BUILD_COMMIT = "deb13a8059a0b1841c255ea2f5fb5461304d32ef";
14
+ const BUILD_VERSION = "0.2.87-preview.168";
15
+ const BUILD_COMMIT = "bd5fddf1656456ae916681fa617df50fb584f997";
16
16
  const BUILD_SENTRY_DSN = "https://fd5e60e1c9820e1eef5ccebb84a07127@o4508714045276160.ingest.us.sentry.io/4510465864564736";
17
17
  const BLAXEL_API_VERSION = "2026-04-16";
18
18
  // Cache for config.yaml tracking value