@mandujs/core 0.53.3 → 0.54.1
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.
- package/README.md +654 -654
- package/package.json +1 -1
- package/src/a11y/__tests__/run-audit.test.ts +333 -333
- package/src/a11y/fix-hints.ts +76 -76
- package/src/a11y/index.ts +18 -18
- package/src/a11y/run-audit.ts +394 -394
- package/src/a11y/types.ts +125 -125
- package/src/auth/__tests__/login.test.ts +1 -1
- package/src/auth/__tests__/password.test.ts +122 -122
- package/src/auth/__tests__/tokens.test.ts +274 -274
- package/src/auth/__tests__/verification.test.ts +274 -274
- package/src/auth/index.ts +76 -76
- package/src/auth/login.ts +225 -225
- package/src/auth/password.ts +120 -120
- package/src/auth/reset.ts +243 -243
- package/src/auth/tokens.ts +612 -612
- package/src/auth/verification.ts +253 -253
- package/src/brain/__tests__/redactor.test.ts +94 -94
- package/src/brain/adapters/__tests__/_helpers.ts +83 -83
- package/src/brain/adapters/__tests__/anthropic-oauth.test.ts +196 -196
- package/src/brain/adapters/__tests__/chatgpt-auth.test.ts +193 -193
- package/src/brain/adapters/__tests__/openai-oauth.test.ts +209 -209
- package/src/brain/adapters/__tests__/resolver.test.ts +143 -143
- package/src/brain/adapters/anthropic-oauth.ts +1 -1
- package/src/brain/adapters/chatgpt-auth.ts +300 -300
- package/src/brain/adapters/index.ts +319 -319
- package/src/brain/adapters/oauth-flow.ts +439 -439
- package/src/brain/consent.ts +240 -240
- package/src/brain/credentials.ts +396 -396
- package/src/bundler/__tests__/build-runner.ts +113 -113
- package/src/bundler/__tests__/dev-reliability.test.ts +619 -619
- package/src/bundler/__tests__/extended-watch.test.ts +711 -711
- package/src/bundler/__tests__/fast-refresh.test.ts +10 -10
- package/src/bundler/__tests__/hdr.test.ts +353 -353
- package/src/bundler/__tests__/hmr-client.test.ts +532 -532
- package/src/bundler/__tests__/manifest-schema.test.ts +266 -266
- package/src/bundler/__tests__/prod-smoke.test.ts +138 -138
- package/src/bundler/__tests__/reverse-import-graph.test.ts +519 -519
- package/src/bundler/__tests__/slot-dispatch.test.ts +573 -573
- package/src/bundler/__tests__/url-cap-and-slot-regex.test.ts +286 -286
- package/src/bundler/__tests__/vendor-cache.test.ts +455 -455
- package/src/bundler/budget.ts +404 -404
- package/src/bundler/build.test.ts +179 -179
- package/src/bundler/build.ts +55 -55
- package/src/bundler/dev.ts +42 -42
- package/src/bundler/manifest-schema.ts +301 -301
- package/src/bundler/plugins/__tests__/block-generated-imports.test.ts +263 -263
- package/src/bundler/plugins/__tests__/react-compiler-config.test.ts +83 -83
- package/src/bundler/plugins/__tests__/react-compiler-lint.test.ts +110 -110
- package/src/bundler/plugins/block-generated-imports.ts +155 -155
- package/src/bundler/plugins/index.ts +83 -83
- package/src/bundler/plugins/react-compiler-config.ts +108 -108
- package/src/bundler/plugins/react-compiler-lint.ts +253 -253
- package/src/bundler/plugins/react-compiler.ts +162 -162
- package/src/bundler/reverse-import-graph.ts +339 -339
- package/src/bundler/safe-build.test.ts +201 -201
- package/src/bundler/safe-build.ts +103 -103
- package/src/bundler/scenario-matrix.ts +229 -229
- package/src/bundler/types.ts +10 -10
- package/src/bundler/vendor-cache-types.ts +130 -130
- package/src/bundler/vendor-cache.ts +526 -526
- package/src/change/snapshot.ts +18 -18
- package/src/client/Form.tsx +105 -105
- package/src/client/Link.tsx +9 -9
- package/src/client/__tests__/use-sse.test.ts +153 -153
- package/src/client/globals.ts +1 -1
- package/src/client/hooks.ts +362 -362
- package/src/client/hydrate.ts +340 -340
- package/src/client/prefetch-helper.ts +55 -55
- package/src/client/router.ts +11 -11
- package/src/client/runtime.ts +47 -47
- package/src/client/serialize.ts +404 -404
- package/src/client/use-fetch.ts +6 -6
- package/src/client/use-head.ts +197 -197
- package/src/client/use-sse.ts +378 -378
- package/src/client/window-state.ts +101 -101
- package/src/components/Image.tsx +162 -162
- package/src/config/validate.ts +3 -3
- package/src/config/watcher.ts +311 -311
- package/src/constants.ts +40 -40
- package/src/content/collection.ts +8 -8
- package/src/content/content-layer.ts +7 -7
- package/src/content/data-store.ts +245 -245
- package/src/content/frontmatter.ts +189 -189
- package/src/content/loader-context.ts +171 -171
- package/src/content/loaders/api.ts +216 -216
- package/src/content/loaders/file.ts +172 -172
- package/src/content/loaders/glob.ts +253 -253
- package/src/content/loaders/index.ts +34 -34
- package/src/content/loaders/types.ts +137 -137
- package/src/content/meta-store.ts +209 -209
- package/src/content/prebuild.test.ts +571 -571
- package/src/content/prebuild.ts +636 -636
- package/src/content/schema.ts +20 -20
- package/src/content/sidebar.ts +630 -630
- package/src/content/slug.ts +110 -110
- package/src/content/types.ts +282 -282
- package/src/content/watcher.ts +135 -135
- package/src/contract/client-safe.test.ts +42 -42
- package/src/contract/client-safe.ts +114 -114
- package/src/contract/define.ts +11 -11
- package/src/contract/index.ts +1 -1
- package/src/contract/normalize.test.ts +276 -276
- package/src/contract/normalize.ts +410 -410
- package/src/contract/registry.test.ts +206 -206
- package/src/contract/route-helpers.ts +1 -1
- package/src/contract/rpc.ts +443 -443
- package/src/contract/schema.ts +48 -48
- package/src/contract/types.ts +58 -58
- package/src/db/__tests__/db.test.ts +482 -482
- package/src/db/index.ts +138 -138
- package/src/db/migrations/history-table.ts +345 -345
- package/src/db/migrations/index.ts +3 -3
- package/src/db/migrations/lock.ts +324 -324
- package/src/db/migrations/runner.ts +650 -650
- package/src/deploy/cache.ts +140 -140
- package/src/deploy/compile/vercel.ts +344 -344
- package/src/deploy/index.ts +87 -87
- package/src/deploy/inference/brain.ts +268 -268
- package/src/deploy/inference/context.ts +182 -182
- package/src/deploy/inference/filling-extract.ts +245 -245
- package/src/deploy/inference/heuristic.ts +182 -182
- package/src/deploy/intent.ts +173 -173
- package/src/deploy/plan.ts +178 -178
- package/src/design/__tests__/agents-link.test.ts +109 -109
- package/src/design/__tests__/extract-patch-diff.test.ts +265 -265
- package/src/design/__tests__/lint.test.ts +110 -110
- package/src/design/__tests__/parser.test.ts +195 -195
- package/src/design/__tests__/tailwind-theme.test.ts +229 -229
- package/src/design/agents-link.ts +165 -165
- package/src/design/diff.ts +138 -138
- package/src/design/extract.ts +285 -285
- package/src/design/index.ts +102 -102
- package/src/design/lint.ts +209 -209
- package/src/design/parser.ts +555 -555
- package/src/design/patch.ts +241 -241
- package/src/design/scaffold.ts +147 -147
- package/src/design/tailwind-theme.ts +441 -441
- package/src/design/types.ts +210 -210
- package/src/desktop/__tests__/webview-fallback.test.ts +254 -254
- package/src/desktop/__tests__/window.test.ts +248 -248
- package/src/desktop/__tests__/worker.test.ts +266 -266
- package/src/desktop/index.ts +43 -43
- package/src/desktop/types.ts +158 -158
- package/src/desktop/worker.ts +180 -180
- package/src/dev-error-overlay/__tests__/overlay-injector.test.ts +241 -241
- package/src/dev-error-overlay/index.ts +30 -30
- package/src/dev-error-overlay/overlay-injector.ts +243 -243
- package/src/dev-error-overlay/overlay-styles.ts +52 -52
- package/src/dev-error-overlay/types.ts +66 -66
- package/src/devtools/ai/context-builder.ts +375 -375
- package/src/devtools/ai/index.ts +25 -25
- package/src/devtools/ai/mcp-connector.ts +25 -25
- package/src/devtools/client/catchers/error-catcher.ts +344 -344
- package/src/devtools/client/catchers/index.ts +18 -18
- package/src/devtools/client/components/index.ts +39 -39
- package/src/devtools/client/components/mandu-character.tsx +331 -331
- package/src/devtools/client/components/overlay.tsx +368 -368
- package/src/devtools/client/components/panel/errors-panel.tsx +259 -259
- package/src/devtools/client/components/panel/guard-panel.tsx +30 -30
- package/src/devtools/client/components/panel/islands-panel.tsx +320 -320
- package/src/devtools/client/components/panel/network-panel.tsx +291 -291
- package/src/devtools/client/components/panel/panel-container.tsx +500 -500
- package/src/devtools/client/components/panel/preview-panel.tsx +46 -46
- package/src/devtools/client/filters/context-filters.ts +282 -282
- package/src/devtools/client/filters/index.ts +16 -16
- package/src/devtools/client/index.ts +63 -63
- package/src/devtools/client/persistence.ts +335 -335
- package/src/devtools/hook/create-hook.ts +207 -207
- package/src/devtools/hook/index.ts +13 -13
- package/src/devtools/index.ts +439 -439
- package/src/devtools/init.ts +265 -265
- package/src/devtools/protocol.ts +237 -237
- package/src/devtools/server/index.ts +17 -17
- package/src/devtools/server/source-context.ts +450 -450
- package/src/devtools/types.ts +35 -35
- package/src/devtools/worker/index.ts +25 -25
- package/src/devtools/worker/redaction-worker.ts +233 -233
- package/src/devtools/worker/worker-manager.ts +410 -410
- package/src/diagnose/__tests__/checks.test.ts +451 -451
- package/src/diagnose/checks.ts +832 -832
- package/src/diagnose/index.ts +17 -17
- package/src/diagnose/run.ts +93 -93
- package/src/diagnose/types.ts +53 -53
- package/src/email/__tests__/email.test.ts +355 -355
- package/src/email/index.ts +282 -282
- package/src/email/smtp.ts +64 -64
- package/src/error/domains.ts +265 -265
- package/src/error/result.ts +60 -60
- package/src/error/types.ts +6 -6
- package/src/errors/extractor.ts +409 -409
- package/src/errors/index.ts +19 -19
- package/src/filling/__tests__/session-sqlite.test.ts +454 -454
- package/src/filling/auth.ts +308 -308
- package/src/filling/cookie-codec.ts +299 -299
- package/src/filling/deps.ts +265 -265
- package/src/filling/session-sqlite.ts +617 -617
- package/src/filling/sse.ts +5 -5
- package/src/filling/ws.ts +78 -78
- package/src/generator/index.ts +3 -3
- package/src/guard/__tests__/design-inline-class.test.ts +219 -219
- package/src/guard/__tests__/tsgolint-bridge.test.ts +347 -347
- package/src/guard/analyzer.ts +360 -360
- package/src/guard/auto-correct.ts +1 -1
- package/src/guard/contract-guard.ts +9 -9
- package/src/guard/define-rule.ts +243 -243
- package/src/guard/design-inline-class.ts +393 -393
- package/src/guard/file-type.test.ts +24 -24
- package/src/guard/graph.ts +1 -1
- package/src/guard/healing.ts +36 -36
- package/src/guard/presets/atomic.ts +70 -70
- package/src/guard/presets/clean.ts +77 -77
- package/src/guard/presets/fsd.ts +79 -79
- package/src/guard/presets/hexagonal.ts +68 -68
- package/src/guard/reporter.ts +442 -442
- package/src/guard/rule-presets.ts +379 -379
- package/src/guard/semantic-slots.ts +1 -1
- package/src/guard/suggestions.ts +358 -358
- package/src/guard/tsgolint-bridge.ts +512 -512
- package/src/guard/types.ts +348 -348
- package/src/guard/watcher.ts +405 -405
- package/src/i18n/define.ts +126 -126
- package/src/i18n/index.ts +52 -52
- package/src/i18n/message-registry.ts +173 -173
- package/src/i18n/types.ts +112 -112
- package/src/id/index.ts +105 -105
- package/src/index.ts +2 -2
- package/src/kitchen/api/agent-devtools-api.ts +779 -544
- package/src/kitchen/api/errors-grouping.ts +126 -0
- package/src/kitchen/kitchen-handler.ts +192 -63
- package/src/kitchen/kitchen-ui.ts +842 -464
- package/src/logging/index.ts +22 -22
- package/src/logging/transports.ts +365 -365
- package/src/middleware/bridge.ts +147 -147
- package/src/middleware/compose.ts +134 -134
- package/src/middleware/compress.ts +62 -62
- package/src/middleware/cors.ts +47 -47
- package/src/middleware/csrf.ts +328 -328
- package/src/middleware/define.ts +132 -132
- package/src/middleware/jwt.ts +134 -134
- package/src/middleware/logger.ts +58 -58
- package/src/middleware/oauth/__tests__/oauth.test.ts +1 -1
- package/src/middleware/oauth/index.ts +505 -505
- package/src/middleware/oauth/providers.ts +115 -115
- package/src/middleware/rate-limit/index.ts +522 -522
- package/src/middleware/rate-limit/sqlite-store.ts +382 -382
- package/src/middleware/scheduler-cron.ts +96 -96
- package/src/middleware/secure/__tests__/secure.test.ts +360 -360
- package/src/middleware/secure/csp.ts +193 -193
- package/src/middleware/session.ts +174 -174
- package/src/middleware/timeout.ts +55 -55
- package/src/observability/logger-adapter.ts +36 -36
- package/src/observability/sqlite-store.ts +254 -254
- package/src/openapi/generator.ts +1 -1
- package/src/openapi/openapi.test.ts +43 -43
- package/src/perf/__tests__/user-marks.test.ts +354 -354
- package/src/perf/index.ts +133 -133
- package/src/plugins/__tests__/lifecycle-integration.test.ts +272 -272
- package/src/plugins/__tests__/runner.test.ts +409 -409
- package/src/plugins/define.ts +124 -124
- package/src/plugins/examples/dep-check-plugin.ts +80 -80
- package/src/plugins/examples/prerender-cache-plugin.ts +111 -111
- package/src/plugins/examples/sitemap-plugin.ts +65 -65
- package/src/plugins/runner.ts +361 -361
- package/src/plugins/types.ts +368 -368
- package/src/report/index.ts +1 -1
- package/src/resource/__tests__/generator.test.ts +32 -32
- package/src/resource/ddl/__tests__/diff.test.ts +639 -639
- package/src/resource/ddl/__tests__/emit.test.ts +823 -823
- package/src/resource/ddl/__tests__/snapshot.test.ts +499 -499
- package/src/resource/ddl/emit.ts +559 -559
- package/src/resource/ddl/persistence-types.ts +218 -218
- package/src/resource/ddl/type-map.ts +223 -223
- package/src/resource/ddl/types.ts +232 -232
- package/src/resource/generator-repo.ts +630 -630
- package/src/resource/schema.ts +1 -1
- package/src/router/fs-patterns.test.ts +96 -96
- package/src/router/fs-routes.ts +6 -6
- package/src/routes/index.ts +74 -74
- package/src/routes/metadata-routes.ts +427 -427
- package/src/routes/types.ts +341 -341
- package/src/runtime/__tests__/error-boundary-redaction.test.ts +141 -141
- package/src/runtime/__tests__/hdr-client.test.ts +223 -223
- package/src/runtime/__tests__/http-errors.test.ts +117 -117
- package/src/runtime/__tests__/not-found.test.ts +152 -152
- package/src/runtime/adapter.ts +47 -47
- package/src/runtime/boundary.tsx +252 -252
- package/src/runtime/cache.ts +494 -494
- package/src/runtime/compose.ts +222 -222
- package/src/runtime/fast-refresh-runtime.ts +322 -322
- package/src/runtime/handler.ts +65 -65
- package/src/runtime/handlers.ts +300 -300
- package/src/runtime/http-errors.ts +113 -113
- package/src/runtime/image-handler.ts +1 -1
- package/src/runtime/lifecycle.ts +381 -381
- package/src/runtime/logger.test.ts +345 -345
- package/src/runtime/middleware.ts +264 -264
- package/src/runtime/not-found.ts +93 -93
- package/src/runtime/openapi-endpoint.ts +236 -236
- package/src/runtime/ppr.ts +74 -74
- package/src/runtime/registry.ts +171 -171
- package/src/runtime/router.ts +105 -105
- package/src/runtime/server.ts +67 -67
- package/src/runtime/shims.ts +48 -48
- package/src/runtime/trace.ts +144 -144
- package/src/scheduler/validate.ts +169 -169
- package/src/seo/index.ts +219 -219
- package/src/seo/integration/ssr.ts +306 -306
- package/src/seo/render/basic.ts +435 -435
- package/src/seo/render/index.ts +143 -143
- package/src/seo/render/jsonld.ts +539 -539
- package/src/seo/render/opengraph.ts +197 -197
- package/src/seo/render/robots.ts +116 -116
- package/src/seo/render/sitemap.ts +137 -137
- package/src/seo/render/twitter.ts +127 -127
- package/src/seo/resolve/opengraph.ts +143 -143
- package/src/seo/resolve/robots.ts +73 -73
- package/src/seo/resolve/title.ts +94 -94
- package/src/seo/resolve/twitter.ts +73 -73
- package/src/seo/resolve/url.ts +104 -104
- package/src/seo/routes/index.ts +290 -290
- package/src/seo/types.ts +588 -588
- package/src/slot/validator.ts +39 -39
- package/src/storage/s3/__tests__/s3.test.ts +479 -479
- package/src/storage/s3/index.ts +412 -412
- package/src/testing/__tests__/assertions.test.ts +632 -632
- package/src/testing/__tests__/reporter.test.ts +454 -454
- package/src/testing/assertions.ts +986 -986
- package/src/testing/db.ts +157 -157
- package/src/testing/mocks.ts +203 -203
- package/src/testing/session.ts +190 -190
- package/src/types/branded.ts +56 -56
- package/src/types/index.ts +1 -1
- package/src/utils/safe-io.ts +188 -188
- package/src/utils/string-safe.ts +298 -298
- package/src/watcher/watcher.ts +18 -18
|
@@ -1,201 +1,201 @@
|
|
|
1
|
-
import { describe, it, expect, beforeEach, afterEach } from "bun:test";
|
|
2
|
-
import { mkdtemp, writeFile, rm } from "fs/promises";
|
|
3
|
-
import { tmpdir } from "os";
|
|
4
|
-
import path from "path";
|
|
5
|
-
|
|
6
|
-
import { safeBuild, _getConcurrencyState } from "./safe-build";
|
|
7
|
-
|
|
8
|
-
/**
|
|
9
|
-
* These tests verify that safeBuild caps concurrent Bun.build invocations.
|
|
10
|
-
* We build tiny entrypoints; correctness of the output is not under test here —
|
|
11
|
-
* that's covered by build.test.ts. We're testing the semaphore only.
|
|
12
|
-
*/
|
|
13
|
-
|
|
14
|
-
let rootDir: string;
|
|
15
|
-
|
|
16
|
-
beforeEach(async () => {
|
|
17
|
-
rootDir = await mkdtemp(path.join(tmpdir(), "mandu-safebuild-test-"));
|
|
18
|
-
});
|
|
19
|
-
|
|
20
|
-
afterEach(async () => {
|
|
21
|
-
try {
|
|
22
|
-
await rm(rootDir, { recursive: true, force: true });
|
|
23
|
-
} catch {
|
|
24
|
-
// Windows may hold locks briefly after Bun.build
|
|
25
|
-
}
|
|
26
|
-
});
|
|
27
|
-
|
|
28
|
-
async function makeEntry(name: string, body = "export const x = 1;\n"): Promise<string> {
|
|
29
|
-
const file = path.join(rootDir, `${name}.ts`);
|
|
30
|
-
await writeFile(file, body);
|
|
31
|
-
return file;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
describe("safeBuild", () => {
|
|
35
|
-
it("returns the same BuildOutput shape as Bun.build", async () => {
|
|
36
|
-
const entry = await makeEntry("a");
|
|
37
|
-
const result = await safeBuild({
|
|
38
|
-
entrypoints: [entry],
|
|
39
|
-
outdir: rootDir,
|
|
40
|
-
target: "browser",
|
|
41
|
-
});
|
|
42
|
-
expect(result.success).toBe(true);
|
|
43
|
-
expect(Array.isArray(result.outputs)).toBe(true);
|
|
44
|
-
expect(result.outputs.length).toBeGreaterThan(0);
|
|
45
|
-
});
|
|
46
|
-
|
|
47
|
-
it("propagates build errors without swallowing them", async () => {
|
|
48
|
-
const entry = await makeEntry("b", "import x from './does-not-exist';\nexport default x;\n");
|
|
49
|
-
// Bun.build either returns { success: false } or throws AggregateError
|
|
50
|
-
// depending on the failure mode. safeBuild must propagate either without
|
|
51
|
-
// altering semantics.
|
|
52
|
-
let caught: unknown = null;
|
|
53
|
-
let result: Awaited<ReturnType<typeof safeBuild>> | null = null;
|
|
54
|
-
try {
|
|
55
|
-
result = await safeBuild({
|
|
56
|
-
entrypoints: [entry],
|
|
57
|
-
outdir: rootDir,
|
|
58
|
-
target: "browser",
|
|
59
|
-
});
|
|
60
|
-
} catch (err) {
|
|
61
|
-
caught = err;
|
|
62
|
-
}
|
|
63
|
-
const softFailed = result !== null && !result.success;
|
|
64
|
-
const hardFailed = caught !== null;
|
|
65
|
-
expect(softFailed || hardFailed).toBe(true);
|
|
66
|
-
});
|
|
67
|
-
|
|
68
|
-
it("caps concurrent builds (never exceeds max) under fan-out", async () => {
|
|
69
|
-
const { max } = _getConcurrencyState();
|
|
70
|
-
expect(max).toBeGreaterThanOrEqual(1);
|
|
71
|
-
|
|
72
|
-
const entries = await Promise.all(
|
|
73
|
-
Array.from({ length: 8 }, (_, i) => makeEntry(`fan-${i}`)),
|
|
74
|
-
);
|
|
75
|
-
|
|
76
|
-
// Sample concurrency peak on a microtask schedule.
|
|
77
|
-
let peak = 0;
|
|
78
|
-
const sampler = setInterval(() => {
|
|
79
|
-
const { active } = _getConcurrencyState();
|
|
80
|
-
if (active > peak) peak = active;
|
|
81
|
-
}, 0);
|
|
82
|
-
|
|
83
|
-
try {
|
|
84
|
-
const results = await Promise.all(
|
|
85
|
-
entries.map((entry) =>
|
|
86
|
-
safeBuild({
|
|
87
|
-
entrypoints: [entry],
|
|
88
|
-
outdir: rootDir,
|
|
89
|
-
target: "browser",
|
|
90
|
-
naming: path.basename(entry, ".ts") + ".[ext]",
|
|
91
|
-
}),
|
|
92
|
-
),
|
|
93
|
-
);
|
|
94
|
-
for (const r of results) {
|
|
95
|
-
expect(r.success).toBe(true);
|
|
96
|
-
}
|
|
97
|
-
} finally {
|
|
98
|
-
clearInterval(sampler);
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
// Peak observed concurrency must be <= max. Exact equality is not
|
|
102
|
-
// guaranteed because all 8 may resolve faster than the sampler ticks,
|
|
103
|
-
// but crucially peak must never exceed the cap.
|
|
104
|
-
expect(peak).toBeLessThanOrEqual(max);
|
|
105
|
-
});
|
|
106
|
-
|
|
107
|
-
it("drains the queue: all builds eventually complete", async () => {
|
|
108
|
-
const entries = await Promise.all(
|
|
109
|
-
Array.from({ length: 6 }, (_, i) => makeEntry(`drain-${i}`)),
|
|
110
|
-
);
|
|
111
|
-
const results = await Promise.all(
|
|
112
|
-
entries.map((entry) =>
|
|
113
|
-
safeBuild({
|
|
114
|
-
entrypoints: [entry],
|
|
115
|
-
outdir: rootDir,
|
|
116
|
-
target: "browser",
|
|
117
|
-
naming: path.basename(entry, ".ts") + ".[ext]",
|
|
118
|
-
}),
|
|
119
|
-
),
|
|
120
|
-
);
|
|
121
|
-
expect(results.every((r) => r.success)).toBe(true);
|
|
122
|
-
|
|
123
|
-
// Semaphore must be fully released
|
|
124
|
-
const state = _getConcurrencyState();
|
|
125
|
-
expect(state.active).toBe(0);
|
|
126
|
-
expect(state.queued).toBe(0);
|
|
127
|
-
});
|
|
128
|
-
|
|
129
|
-
it("slot handoff — new callers cannot bypass queued waiters (regression for cap+1 race)", async () => {
|
|
130
|
-
// Scenario: a build completes with a waiter queued; a NEW safeBuild call
|
|
131
|
-
// fires on the same microtask. A prior revision decremented `active`
|
|
132
|
-
// before resolving the waiter, leaving a microtask-sized window where
|
|
133
|
-
// the new caller saw `active < max`, skipped the wait, and became the
|
|
134
|
-
// cap+1 concurrent build. This test launches 3*max + 1 builds, samples
|
|
135
|
-
// active at every slot release, and asserts the peak never exceeds max.
|
|
136
|
-
const { max } = _getConcurrencyState();
|
|
137
|
-
const N = max * 3 + 1; // always enough to trigger at least one handoff
|
|
138
|
-
const entries = await Promise.all(
|
|
139
|
-
Array.from({ length: N }, (_, i) => makeEntry(`handoff-${i}`)),
|
|
140
|
-
);
|
|
141
|
-
|
|
142
|
-
let peak = 0;
|
|
143
|
-
let samples = 0;
|
|
144
|
-
// Sample active-slot count while the build burst is in flight. An earlier
|
|
145
|
-
// revision of this test used a `while (!stop) { await Promise.resolve() }`
|
|
146
|
-
// microtask busy-loop to push sampling granularity below setInterval's
|
|
147
|
-
// Windows 4ms-ish clamp. That deadlocks under Bun 1.3.x on Windows:
|
|
148
|
-
// `await Promise.resolve()` stays on the microtask queue, which runs
|
|
149
|
-
// to exhaustion before Bun's libuv I/O phase — so Bun.build completion
|
|
150
|
-
// callbacks never fire, `releaseSlot()` never runs, and the promises
|
|
151
|
-
// returned by the 7 parallel `safeBuild()` calls hang indefinitely.
|
|
152
|
-
// Reproduction: `bun test src/bundler/safe-build.test.ts` times out with
|
|
153
|
-
// only the banner printed (confirmed with a standalone repro of the
|
|
154
|
-
// sampler + 7 safeBuild calls — hung at "start" past 60s).
|
|
155
|
-
//
|
|
156
|
-
// Fix: yield to the macrotask queue via `setImmediate`. This lets
|
|
157
|
-
// libuv I/O callbacks run between samples, so Bun.build completes and
|
|
158
|
-
// `releaseSlot()` advances the queue. Per-tick granularity on Node/Bun
|
|
159
|
-
// is still sub-millisecond and fires ~hundreds of times during a 7-
|
|
160
|
-
// build burst — more than enough to statistically catch the cap+1
|
|
161
|
-
// regression window if it ever returned (the window is microtask-sized,
|
|
162
|
-
// but any cross-tick sampling with high fan-out has a realistic chance
|
|
163
|
-
// of landing inside it). The strict assertion is still `peak <= max`.
|
|
164
|
-
let stop = false;
|
|
165
|
-
const sample = async () => {
|
|
166
|
-
for (;;) {
|
|
167
|
-
if (stop) break;
|
|
168
|
-
const { active } = _getConcurrencyState();
|
|
169
|
-
if (active > peak) peak = active;
|
|
170
|
-
samples++;
|
|
171
|
-
// Yield to libuv I/O phase so Bun.build callbacks can fire.
|
|
172
|
-
await new Promise<void>((resolve) => setImmediate(resolve));
|
|
173
|
-
}
|
|
174
|
-
};
|
|
175
|
-
const sampler = sample();
|
|
176
|
-
|
|
177
|
-
try {
|
|
178
|
-
const results = await Promise.all(
|
|
179
|
-
entries.map((entry) =>
|
|
180
|
-
safeBuild({
|
|
181
|
-
entrypoints: [entry],
|
|
182
|
-
outdir: rootDir,
|
|
183
|
-
target: "browser",
|
|
184
|
-
naming: path.basename(entry, ".ts") + ".[ext]",
|
|
185
|
-
}),
|
|
186
|
-
),
|
|
187
|
-
);
|
|
188
|
-
expect(results.every((r) => r.success)).toBe(true);
|
|
189
|
-
} finally {
|
|
190
|
-
stop = true;
|
|
191
|
-
await sampler;
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
expect(peak).toBeLessThanOrEqual(max);
|
|
195
|
-
expect(samples).toBeGreaterThan(0);
|
|
196
|
-
|
|
197
|
-
const state = _getConcurrencyState();
|
|
198
|
-
expect(state.active).toBe(0);
|
|
199
|
-
expect(state.queued).toBe(0);
|
|
200
|
-
});
|
|
201
|
-
});
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from "bun:test";
|
|
2
|
+
import { mkdtemp, writeFile, rm } from "fs/promises";
|
|
3
|
+
import { tmpdir } from "os";
|
|
4
|
+
import path from "path";
|
|
5
|
+
|
|
6
|
+
import { safeBuild, _getConcurrencyState } from "./safe-build";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* These tests verify that safeBuild caps concurrent Bun.build invocations.
|
|
10
|
+
* We build tiny entrypoints; correctness of the output is not under test here —
|
|
11
|
+
* that's covered by build.test.ts. We're testing the semaphore only.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
let rootDir: string;
|
|
15
|
+
|
|
16
|
+
beforeEach(async () => {
|
|
17
|
+
rootDir = await mkdtemp(path.join(tmpdir(), "mandu-safebuild-test-"));
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
afterEach(async () => {
|
|
21
|
+
try {
|
|
22
|
+
await rm(rootDir, { recursive: true, force: true });
|
|
23
|
+
} catch {
|
|
24
|
+
// Windows may hold locks briefly after Bun.build
|
|
25
|
+
}
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
async function makeEntry(name: string, body = "export const x = 1;\n"): Promise<string> {
|
|
29
|
+
const file = path.join(rootDir, `${name}.ts`);
|
|
30
|
+
await writeFile(file, body);
|
|
31
|
+
return file;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
describe("safeBuild", () => {
|
|
35
|
+
it("returns the same BuildOutput shape as Bun.build", async () => {
|
|
36
|
+
const entry = await makeEntry("a");
|
|
37
|
+
const result = await safeBuild({
|
|
38
|
+
entrypoints: [entry],
|
|
39
|
+
outdir: rootDir,
|
|
40
|
+
target: "browser",
|
|
41
|
+
});
|
|
42
|
+
expect(result.success).toBe(true);
|
|
43
|
+
expect(Array.isArray(result.outputs)).toBe(true);
|
|
44
|
+
expect(result.outputs.length).toBeGreaterThan(0);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("propagates build errors without swallowing them", async () => {
|
|
48
|
+
const entry = await makeEntry("b", "import x from './does-not-exist';\nexport default x;\n");
|
|
49
|
+
// Bun.build either returns { success: false } or throws AggregateError
|
|
50
|
+
// depending on the failure mode. safeBuild must propagate either without
|
|
51
|
+
// altering semantics.
|
|
52
|
+
let caught: unknown = null;
|
|
53
|
+
let result: Awaited<ReturnType<typeof safeBuild>> | null = null;
|
|
54
|
+
try {
|
|
55
|
+
result = await safeBuild({
|
|
56
|
+
entrypoints: [entry],
|
|
57
|
+
outdir: rootDir,
|
|
58
|
+
target: "browser",
|
|
59
|
+
});
|
|
60
|
+
} catch (err) {
|
|
61
|
+
caught = err;
|
|
62
|
+
}
|
|
63
|
+
const softFailed = result !== null && !result.success;
|
|
64
|
+
const hardFailed = caught !== null;
|
|
65
|
+
expect(softFailed || hardFailed).toBe(true);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("caps concurrent builds (never exceeds max) under fan-out", async () => {
|
|
69
|
+
const { max } = _getConcurrencyState();
|
|
70
|
+
expect(max).toBeGreaterThanOrEqual(1);
|
|
71
|
+
|
|
72
|
+
const entries = await Promise.all(
|
|
73
|
+
Array.from({ length: 8 }, (_, i) => makeEntry(`fan-${i}`)),
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
// Sample concurrency peak on a microtask schedule.
|
|
77
|
+
let peak = 0;
|
|
78
|
+
const sampler = setInterval(() => {
|
|
79
|
+
const { active } = _getConcurrencyState();
|
|
80
|
+
if (active > peak) peak = active;
|
|
81
|
+
}, 0);
|
|
82
|
+
|
|
83
|
+
try {
|
|
84
|
+
const results = await Promise.all(
|
|
85
|
+
entries.map((entry) =>
|
|
86
|
+
safeBuild({
|
|
87
|
+
entrypoints: [entry],
|
|
88
|
+
outdir: rootDir,
|
|
89
|
+
target: "browser",
|
|
90
|
+
naming: path.basename(entry, ".ts") + ".[ext]",
|
|
91
|
+
}),
|
|
92
|
+
),
|
|
93
|
+
);
|
|
94
|
+
for (const r of results) {
|
|
95
|
+
expect(r.success).toBe(true);
|
|
96
|
+
}
|
|
97
|
+
} finally {
|
|
98
|
+
clearInterval(sampler);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Peak observed concurrency must be <= max. Exact equality is not
|
|
102
|
+
// guaranteed because all 8 may resolve faster than the sampler ticks,
|
|
103
|
+
// but crucially peak must never exceed the cap.
|
|
104
|
+
expect(peak).toBeLessThanOrEqual(max);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("drains the queue: all builds eventually complete", async () => {
|
|
108
|
+
const entries = await Promise.all(
|
|
109
|
+
Array.from({ length: 6 }, (_, i) => makeEntry(`drain-${i}`)),
|
|
110
|
+
);
|
|
111
|
+
const results = await Promise.all(
|
|
112
|
+
entries.map((entry) =>
|
|
113
|
+
safeBuild({
|
|
114
|
+
entrypoints: [entry],
|
|
115
|
+
outdir: rootDir,
|
|
116
|
+
target: "browser",
|
|
117
|
+
naming: path.basename(entry, ".ts") + ".[ext]",
|
|
118
|
+
}),
|
|
119
|
+
),
|
|
120
|
+
);
|
|
121
|
+
expect(results.every((r) => r.success)).toBe(true);
|
|
122
|
+
|
|
123
|
+
// Semaphore must be fully released
|
|
124
|
+
const state = _getConcurrencyState();
|
|
125
|
+
expect(state.active).toBe(0);
|
|
126
|
+
expect(state.queued).toBe(0);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("slot handoff — new callers cannot bypass queued waiters (regression for cap+1 race)", async () => {
|
|
130
|
+
// Scenario: a build completes with a waiter queued; a NEW safeBuild call
|
|
131
|
+
// fires on the same microtask. A prior revision decremented `active`
|
|
132
|
+
// before resolving the waiter, leaving a microtask-sized window where
|
|
133
|
+
// the new caller saw `active < max`, skipped the wait, and became the
|
|
134
|
+
// cap+1 concurrent build. This test launches 3*max + 1 builds, samples
|
|
135
|
+
// active at every slot release, and asserts the peak never exceeds max.
|
|
136
|
+
const { max } = _getConcurrencyState();
|
|
137
|
+
const N = max * 3 + 1; // always enough to trigger at least one handoff
|
|
138
|
+
const entries = await Promise.all(
|
|
139
|
+
Array.from({ length: N }, (_, i) => makeEntry(`handoff-${i}`)),
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
let peak = 0;
|
|
143
|
+
let samples = 0;
|
|
144
|
+
// Sample active-slot count while the build burst is in flight. An earlier
|
|
145
|
+
// revision of this test used a `while (!stop) { await Promise.resolve() }`
|
|
146
|
+
// microtask busy-loop to push sampling granularity below setInterval's
|
|
147
|
+
// Windows 4ms-ish clamp. That deadlocks under Bun 1.3.x on Windows:
|
|
148
|
+
// `await Promise.resolve()` stays on the microtask queue, which runs
|
|
149
|
+
// to exhaustion before Bun's libuv I/O phase — so Bun.build completion
|
|
150
|
+
// callbacks never fire, `releaseSlot()` never runs, and the promises
|
|
151
|
+
// returned by the 7 parallel `safeBuild()` calls hang indefinitely.
|
|
152
|
+
// Reproduction: `bun test src/bundler/safe-build.test.ts` times out with
|
|
153
|
+
// only the banner printed (confirmed with a standalone repro of the
|
|
154
|
+
// sampler + 7 safeBuild calls — hung at "start" past 60s).
|
|
155
|
+
//
|
|
156
|
+
// Fix: yield to the macrotask queue via `setImmediate`. This lets
|
|
157
|
+
// libuv I/O callbacks run between samples, so Bun.build completes and
|
|
158
|
+
// `releaseSlot()` advances the queue. Per-tick granularity on Node/Bun
|
|
159
|
+
// is still sub-millisecond and fires ~hundreds of times during a 7-
|
|
160
|
+
// build burst — more than enough to statistically catch the cap+1
|
|
161
|
+
// regression window if it ever returned (the window is microtask-sized,
|
|
162
|
+
// but any cross-tick sampling with high fan-out has a realistic chance
|
|
163
|
+
// of landing inside it). The strict assertion is still `peak <= max`.
|
|
164
|
+
let stop = false;
|
|
165
|
+
const sample = async () => {
|
|
166
|
+
for (;;) {
|
|
167
|
+
if (stop) break;
|
|
168
|
+
const { active } = _getConcurrencyState();
|
|
169
|
+
if (active > peak) peak = active;
|
|
170
|
+
samples++;
|
|
171
|
+
// Yield to libuv I/O phase so Bun.build callbacks can fire.
|
|
172
|
+
await new Promise<void>((resolve) => setImmediate(resolve));
|
|
173
|
+
}
|
|
174
|
+
};
|
|
175
|
+
const sampler = sample();
|
|
176
|
+
|
|
177
|
+
try {
|
|
178
|
+
const results = await Promise.all(
|
|
179
|
+
entries.map((entry) =>
|
|
180
|
+
safeBuild({
|
|
181
|
+
entrypoints: [entry],
|
|
182
|
+
outdir: rootDir,
|
|
183
|
+
target: "browser",
|
|
184
|
+
naming: path.basename(entry, ".ts") + ".[ext]",
|
|
185
|
+
}),
|
|
186
|
+
),
|
|
187
|
+
);
|
|
188
|
+
expect(results.every((r) => r.success)).toBe(true);
|
|
189
|
+
} finally {
|
|
190
|
+
stop = true;
|
|
191
|
+
await sampler;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
expect(peak).toBeLessThanOrEqual(max);
|
|
195
|
+
expect(samples).toBeGreaterThan(0);
|
|
196
|
+
|
|
197
|
+
const state = _getConcurrencyState();
|
|
198
|
+
expect(state.active).toBe(0);
|
|
199
|
+
expect(state.queued).toBe(0);
|
|
200
|
+
});
|
|
201
|
+
});
|
|
@@ -1,103 +1,103 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Concurrency-limited wrapper around `Bun.build`.
|
|
3
|
-
*
|
|
4
|
-
* Why this exists:
|
|
5
|
-
* - `Bun.build` exhibits failure modes under high concurrent invocation —
|
|
6
|
-
* observed as `AggregateError: Bundle failed` with missing output shims
|
|
7
|
-
* (e.g. `_react-dom-client.js`) when 5+ builds fire in parallel and
|
|
8
|
-
* multiple test worker processes pile on simultaneously.
|
|
9
|
-
* - Our own `buildClientBundles` fans out vendor-shim builds via `Promise.all`
|
|
10
|
-
* (5 concurrent). Combined with parallel test workers or concurrent dev-mode
|
|
11
|
-
* rebuilds, total concurrent `Bun.build` invocations can exceed what the
|
|
12
|
-
* runtime/OS resolves reliably, yielding intermittent failures that are
|
|
13
|
-
* hard to diagnose.
|
|
14
|
-
*
|
|
15
|
-
* Design:
|
|
16
|
-
* - Process-wide semaphore. Default cap: `2` concurrent `Bun.build` per
|
|
17
|
-
* process. Tunable via `MANDU_BUN_BUILD_CONCURRENCY` (positive integer).
|
|
18
|
-
* - FIFO queue — no priority, no cancellation. Callers get `Bun.build`'s
|
|
19
|
-
* `BuildOutput` result (or exception) transparently.
|
|
20
|
-
* - In-process only. Cross-worker coordination is not this module's job;
|
|
21
|
-
* per-worker throttling already prevents the observed failure modes in
|
|
22
|
-
* our test matrix.
|
|
23
|
-
*
|
|
24
|
-
* Correctness — slot handoff:
|
|
25
|
-
* - The semaphore does slot *handoff* rather than release-then-acquire. When
|
|
26
|
-
* a build completes with waiters queued, the slot is transferred directly
|
|
27
|
-
* to the next waiter (active count never drops). A previous revision had
|
|
28
|
-
* a classic acquire race: `releaseSlot()` decremented `active` before the
|
|
29
|
-
* waiter's `active++` ran, which opened a microtask-sized window where a
|
|
30
|
-
* concurrent `safeBuild()` call could observe `active < max`, skip the
|
|
31
|
-
* wait, and join in — yielding `cap+1` concurrent builds.
|
|
32
|
-
*/
|
|
33
|
-
|
|
34
|
-
import type { BuildConfig, BuildOutput } from "bun";
|
|
35
|
-
|
|
36
|
-
const DEFAULT_MAX_CONCURRENT = 2;
|
|
37
|
-
|
|
38
|
-
function parseMaxConcurrent(): number {
|
|
39
|
-
const raw = process.env.MANDU_BUN_BUILD_CONCURRENCY;
|
|
40
|
-
if (!raw) return DEFAULT_MAX_CONCURRENT;
|
|
41
|
-
const parsed = Number.parseInt(raw, 10);
|
|
42
|
-
if (!Number.isFinite(parsed) || parsed < 1) return DEFAULT_MAX_CONCURRENT;
|
|
43
|
-
return parsed;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
const maxConcurrent = parseMaxConcurrent();
|
|
47
|
-
let active = 0;
|
|
48
|
-
const waiters: Array<() => void> = [];
|
|
49
|
-
|
|
50
|
-
/**
|
|
51
|
-
* Acquire a slot. When `active < max`, increment synchronously and return.
|
|
52
|
-
* Otherwise, queue and await a direct handoff from a completing build —
|
|
53
|
-
* the completing build does NOT decrement `active`; it resolves our waiter,
|
|
54
|
-
* and `active` stays at `max` through the transition. This prevents a
|
|
55
|
-
* microtask-window race where a third caller could see `active < max` and
|
|
56
|
-
* skip the wait entirely.
|
|
57
|
-
*/
|
|
58
|
-
function acquireSlot(): Promise<void> {
|
|
59
|
-
if (active < maxConcurrent) {
|
|
60
|
-
active++;
|
|
61
|
-
return Promise.resolve();
|
|
62
|
-
}
|
|
63
|
-
return new Promise<void>((resolve) => {
|
|
64
|
-
waiters.push(resolve);
|
|
65
|
-
});
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
/**
|
|
69
|
-
* Release a slot. If a waiter is queued, hand the slot off directly (keep
|
|
70
|
-
* `active` at `max`, resolve the waiter). Otherwise decrement `active`.
|
|
71
|
-
*/
|
|
72
|
-
function releaseSlot(): void {
|
|
73
|
-
const next = waiters.shift();
|
|
74
|
-
if (next) {
|
|
75
|
-
// Direct handoff — `active` stays at max, waiter resumes holding the slot.
|
|
76
|
-
next();
|
|
77
|
-
} else {
|
|
78
|
-
active--;
|
|
79
|
-
}
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
/**
|
|
83
|
-
* Runs `Bun.build(options)` subject to a process-wide concurrency cap.
|
|
84
|
-
* Preserves the exact return type and error semantics of `Bun.build`.
|
|
85
|
-
*
|
|
86
|
-
* Note: this wrapper only caps concurrency *within* a single process. Test
|
|
87
|
-
* harnesses that spawn multiple worker processes still see each worker run
|
|
88
|
-
* up to `maxConcurrent` concurrent builds — see Phase 0.6 for cross-process
|
|
89
|
-
* coordination work.
|
|
90
|
-
*/
|
|
91
|
-
export async function safeBuild(options: BuildConfig): Promise<BuildOutput> {
|
|
92
|
-
await acquireSlot();
|
|
93
|
-
try {
|
|
94
|
-
return await Bun.build(options);
|
|
95
|
-
} finally {
|
|
96
|
-
releaseSlot();
|
|
97
|
-
}
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
/** Exposed for tests — not part of the public API. */
|
|
101
|
-
export function _getConcurrencyState() {
|
|
102
|
-
return { active, queued: waiters.length, max: maxConcurrent };
|
|
103
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Concurrency-limited wrapper around `Bun.build`.
|
|
3
|
+
*
|
|
4
|
+
* Why this exists:
|
|
5
|
+
* - `Bun.build` exhibits failure modes under high concurrent invocation —
|
|
6
|
+
* observed as `AggregateError: Bundle failed` with missing output shims
|
|
7
|
+
* (e.g. `_react-dom-client.js`) when 5+ builds fire in parallel and
|
|
8
|
+
* multiple test worker processes pile on simultaneously.
|
|
9
|
+
* - Our own `buildClientBundles` fans out vendor-shim builds via `Promise.all`
|
|
10
|
+
* (5 concurrent). Combined with parallel test workers or concurrent dev-mode
|
|
11
|
+
* rebuilds, total concurrent `Bun.build` invocations can exceed what the
|
|
12
|
+
* runtime/OS resolves reliably, yielding intermittent failures that are
|
|
13
|
+
* hard to diagnose.
|
|
14
|
+
*
|
|
15
|
+
* Design:
|
|
16
|
+
* - Process-wide semaphore. Default cap: `2` concurrent `Bun.build` per
|
|
17
|
+
* process. Tunable via `MANDU_BUN_BUILD_CONCURRENCY` (positive integer).
|
|
18
|
+
* - FIFO queue — no priority, no cancellation. Callers get `Bun.build`'s
|
|
19
|
+
* `BuildOutput` result (or exception) transparently.
|
|
20
|
+
* - In-process only. Cross-worker coordination is not this module's job;
|
|
21
|
+
* per-worker throttling already prevents the observed failure modes in
|
|
22
|
+
* our test matrix.
|
|
23
|
+
*
|
|
24
|
+
* Correctness — slot handoff:
|
|
25
|
+
* - The semaphore does slot *handoff* rather than release-then-acquire. When
|
|
26
|
+
* a build completes with waiters queued, the slot is transferred directly
|
|
27
|
+
* to the next waiter (active count never drops). A previous revision had
|
|
28
|
+
* a classic acquire race: `releaseSlot()` decremented `active` before the
|
|
29
|
+
* waiter's `active++` ran, which opened a microtask-sized window where a
|
|
30
|
+
* concurrent `safeBuild()` call could observe `active < max`, skip the
|
|
31
|
+
* wait, and join in — yielding `cap+1` concurrent builds.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import type { BuildConfig, BuildOutput } from "bun";
|
|
35
|
+
|
|
36
|
+
const DEFAULT_MAX_CONCURRENT = 2;
|
|
37
|
+
|
|
38
|
+
function parseMaxConcurrent(): number {
|
|
39
|
+
const raw = process.env.MANDU_BUN_BUILD_CONCURRENCY;
|
|
40
|
+
if (!raw) return DEFAULT_MAX_CONCURRENT;
|
|
41
|
+
const parsed = Number.parseInt(raw, 10);
|
|
42
|
+
if (!Number.isFinite(parsed) || parsed < 1) return DEFAULT_MAX_CONCURRENT;
|
|
43
|
+
return parsed;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const maxConcurrent = parseMaxConcurrent();
|
|
47
|
+
let active = 0;
|
|
48
|
+
const waiters: Array<() => void> = [];
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Acquire a slot. When `active < max`, increment synchronously and return.
|
|
52
|
+
* Otherwise, queue and await a direct handoff from a completing build —
|
|
53
|
+
* the completing build does NOT decrement `active`; it resolves our waiter,
|
|
54
|
+
* and `active` stays at `max` through the transition. This prevents a
|
|
55
|
+
* microtask-window race where a third caller could see `active < max` and
|
|
56
|
+
* skip the wait entirely.
|
|
57
|
+
*/
|
|
58
|
+
function acquireSlot(): Promise<void> {
|
|
59
|
+
if (active < maxConcurrent) {
|
|
60
|
+
active++;
|
|
61
|
+
return Promise.resolve();
|
|
62
|
+
}
|
|
63
|
+
return new Promise<void>((resolve) => {
|
|
64
|
+
waiters.push(resolve);
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Release a slot. If a waiter is queued, hand the slot off directly (keep
|
|
70
|
+
* `active` at `max`, resolve the waiter). Otherwise decrement `active`.
|
|
71
|
+
*/
|
|
72
|
+
function releaseSlot(): void {
|
|
73
|
+
const next = waiters.shift();
|
|
74
|
+
if (next) {
|
|
75
|
+
// Direct handoff — `active` stays at max, waiter resumes holding the slot.
|
|
76
|
+
next();
|
|
77
|
+
} else {
|
|
78
|
+
active--;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Runs `Bun.build(options)` subject to a process-wide concurrency cap.
|
|
84
|
+
* Preserves the exact return type and error semantics of `Bun.build`.
|
|
85
|
+
*
|
|
86
|
+
* Note: this wrapper only caps concurrency *within* a single process. Test
|
|
87
|
+
* harnesses that spawn multiple worker processes still see each worker run
|
|
88
|
+
* up to `maxConcurrent` concurrent builds — see Phase 0.6 for cross-process
|
|
89
|
+
* coordination work.
|
|
90
|
+
*/
|
|
91
|
+
export async function safeBuild(options: BuildConfig): Promise<BuildOutput> {
|
|
92
|
+
await acquireSlot();
|
|
93
|
+
try {
|
|
94
|
+
return await Bun.build(options);
|
|
95
|
+
} finally {
|
|
96
|
+
releaseSlot();
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Exposed for tests — not part of the public API. */
|
|
101
|
+
export function _getConcurrencyState() {
|
|
102
|
+
return { active, queued: waiters.length, max: maxConcurrent };
|
|
103
|
+
}
|