@dimina-kit/compiler 0.0.1-dev.20260706131625 → 0.0.1-dev.20260707070110

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dimina-kit/compiler",
3
- "version": "0.0.1-dev.20260706131625",
3
+ "version": "0.0.1-dev.20260707070110",
4
4
  "description": "dmcc compiler bundles (browser + node) that drive @dimina/compiler against a caller-injected node:fs replacement (no bundled fs)",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -83,6 +83,7 @@
83
83
  "test:pool-node": "node scripts/build-compiler.js node && node --import ./scripts/register-kit.js scripts/test-pool-node.js",
84
84
  "test:pool-scopehash": "node scripts/build-compiler.js node && node --import ./scripts/register-kit.js scripts/test-pool-scopehash.js",
85
85
  "test:pool-hardening": "node scripts/build-compiler.js node && node scripts/test-pool-hardening.js",
86
+ "test:pool-toolchain-death": "node scripts/build-compiler.js node && node scripts/test-pool-toolchain-death.js",
86
87
  "test:pool-worker-hardening": "node scripts/test-pool-worker-hardening.js",
87
88
  "test:worker-slot": "node scripts/test-worker-slot.js",
88
89
  "test:pool-watchdog": "node scripts/test-pool-watchdog.js",
@@ -0,0 +1,135 @@
1
+ // Regression coverage for the resident Node disk pool's handling of a DEAD esbuild
2
+ // toolchain service — the Windows-packaged-app crash pattern: esbuild's JS lib spawns
3
+ // a long-lived binary child process (the "service"), and in a packaged Electron app
4
+ // that spawn can fail (e.g. the binary sits inside app.asar, which fs.existsSync()
5
+ // sees but child_process.spawn() cannot exec). Once that service is dead inside a
6
+ // warm worker realm, EVERY later esbuild call in that same realm fails with
7
+ // "The service is no longer running: write EPIPE" — a message shape that looks
8
+ // nothing like the real cause. Today the pool classifies this as
9
+ // `code: 'compiler-stage-error'` ("deterministic, never retried") and keeps the
10
+ // broken worker, so the pool never recovers even after the environment is healed.
11
+ //
12
+ // Fault injection needs no source changes: esbuild's node lib honors
13
+ // ESBUILD_BINARY_PATH, and each worker_threads realm captures a COPY of
14
+ // process.env at spawn. Pointing that var (before the pool spawns its workers) at
15
+ // a real, executable file whose shebang interpreter does not exist reproduces the
16
+ // exact "fs.existsSync() true, spawn() ENOENT" split a binary trapped inside
17
+ // app.asar produces — child_process.spawn() reports ENOENT because the kernel's
18
+ // own shebang-interpreter lookup fails, not because the file is missing.
19
+ import fs from 'node:fs'
20
+ import path from 'node:path'
21
+ import { fileURLToPath } from 'node:url'
22
+
23
+ const APP = process.env.APP_DIR
24
+ || fileURLToPath(new URL('../../../dimina/fe/example/base', import.meta.url))
25
+ const TMP = fileURLToPath(new URL('../.tmp-pool-toolchain-death/', import.meta.url))
26
+ fs.rmSync(TMP, { recursive: true, force: true })
27
+ fs.mkdirSync(TMP, { recursive: true })
28
+ const dir = (n) => path.join(TMP, n)
29
+
30
+ const FAKE_ESBUILD = path.join(TMP, 'fake-esbuild-service')
31
+ fs.writeFileSync(FAKE_ESBUILD, '#!/nonexistent/esbuild-interpreter\necho unreachable\n')
32
+ fs.chmodSync(FAKE_ESBUILD, 0o755)
33
+
34
+ let failed = false
35
+ const chk = (cond, msg) => { if (!cond) { failed = true; console.error(`❌ ${msg}`) } else console.log(`✅ ${msg}`) }
36
+ const withTimeout = (p, ms, label) => Promise.race([
37
+ p,
38
+ new Promise((_, rej) => setTimeout(() => rej(new Error(`TIMEOUT: ${label} did not settle in ${ms}ms`)), ms).unref()),
39
+ ])
40
+ const settle = (p) => p.then((v) => ({ ok: true, v }), (e) => ({ ok: false, e }))
41
+ const treeOk = (d, i) => fs.existsSync(path.join(d, i.appId, 'main', 'logic.js'))
42
+
43
+ // Property access (not a named import) so a dist build that has not yet added
44
+ // esbuildAsarSpawnHint fails this script's own assertion instead of a module-link
45
+ // SyntaxError that would abort before any other scenario runs.
46
+ const poolModule = await import('../dist/pool.node.js')
47
+ const { createNodeCompilerPool } = poolModule
48
+ const esbuildAsarSpawnHint = poolModule.esbuildAsarSpawnHint
49
+
50
+ // --- classification + recycle ------------------------------------------------------
51
+ // A dead esbuild service must be coded distinctly from a real compile error, and the
52
+ // worker(s) that hit it must be recycled so the NEXT build — once the environment is
53
+ // healed — runs on a fresh realm instead of replaying the same dead service forever.
54
+ process.env.ESBUILD_BINARY_PATH = FAKE_ESBUILD
55
+ const pool = createNodeCompilerPool({ retryOnWorkerDeath: false })
56
+ // Read immediately after creation, before the pool's own eager ensureAlive() settles —
57
+ // the pristine pre-spawn baseline every later generation count is compared against.
58
+ const gensPristine = pool._slots.map((x) => x.slot.generation)
59
+
60
+ const first = await withTimeout(
61
+ settle(pool.build(dir('a1'), APP, true, {})), 60000, 'first build against a dead esbuild service',
62
+ )
63
+ chk(!first.ok, 'first build against a dead esbuild service rejects')
64
+ const firstErr = first.ok ? null : first.e
65
+ chk(!!firstErr && firstErr.code === 'compiler-toolchain-dead',
66
+ `dead-esbuild-service failure is coded 'compiler-toolchain-dead' (got ${firstErr && firstErr.code})`)
67
+ chk(!!firstErr && typeof firstErr.stage === 'string' && firstErr.stage.length > 0,
68
+ `error carries .stage (got ${firstErr && firstErr.stage})`)
69
+ chk(!!firstErr && /^\[compiler\] stage "[^"]+" failed: .*The service (was stopped|is no longer running)/.test(firstErr.message || ''),
70
+ `error message keeps the stage-shaped prefix around the dead-service cause: ${firstErr && firstErr.message}`)
71
+
72
+ // Heal the environment the way a real fix would (repackage / relaunch): only NEWLY
73
+ // spawned worker realms see this — the pool must actually recycle, not get lucky that
74
+ // the already-spawned realm somehow un-breaks itself.
75
+ delete process.env.ESBUILD_BINARY_PATH
76
+
77
+ const second = await withTimeout(
78
+ settle(pool.build(dir('a2'), APP, true, {})), 60000, 'build after healing the toolchain env',
79
+ )
80
+ chk(second.ok && treeOk(dir('a2'), second.v),
81
+ `build after healing the env succeeds via recycled workers, not a permanent write EPIPE: ${second.ok ? 'ok' : second.e && second.e.message}`)
82
+
83
+ const gensAfterHeal = pool._slots.map((x) => x.slot.generation)
84
+ chk(gensAfterHeal.some((g, i) => g - gensPristine[i] > 1),
85
+ `pool recycled at least one worker slot between the dead-service failure and the next build (pristine ${gensPristine.join(',')} -> after-heal ${gensAfterHeal.join(',')})`)
86
+
87
+ await pool.dispose()
88
+
89
+ // --- transparent retry on a persistently broken toolchain --------------------------
90
+ // retryOnWorkerDeath defaults to true and already gives worker crashes one whole-build
91
+ // retry; a dead-toolchain failure must get the same treatment. With the environment
92
+ // STILL broken the retry cannot succeed either, so the build must still reject (no
93
+ // hang) — but by then the affected slot(s) should have been recycled on both the
94
+ // initial attempt and its retry.
95
+ process.env.ESBUILD_BINARY_PATH = FAKE_ESBUILD
96
+ const poolR = createNodeCompilerPool() // retryOnWorkerDeath defaults to true
97
+ const gensPristineR = poolR._slots.map((x) => x.slot.generation)
98
+
99
+ const retried = await withTimeout(
100
+ settle(poolR.build(dir('r1'), APP, true, {})), 120000, 'build against a persistently dead esbuild service with retry enabled',
101
+ )
102
+ chk(!retried.ok, 'build against a persistently dead toolchain still rejects rather than hanging')
103
+ chk(!retried.ok && retried.e && retried.e.code === 'compiler-toolchain-dead',
104
+ `the final rejection after the transparent retry is still coded 'compiler-toolchain-dead' (got ${retried.ok ? undefined : retried.e && retried.e.code})`)
105
+
106
+ const gensAfterRetry = poolR._slots.map((x) => x.slot.generation)
107
+ chk(gensAfterRetry.some((g, i) => g - gensPristineR[i] > 1),
108
+ `a persistently dead toolchain is recycled on both the initial attempt and its retry (pristine ${gensPristineR.join(',')} -> after-retry ${gensAfterRetry.join(',')})`)
109
+
110
+ await poolR.dispose()
111
+ delete process.env.ESBUILD_BINARY_PATH
112
+
113
+ // --- pure helper: actionable asar-unpack hint for a dead esbuild service ------------
114
+ chk(typeof esbuildAsarSpawnHint === 'function', 'esbuildAsarSpawnHint is exported from the pool module')
115
+ if (typeof esbuildAsarSpawnHint === 'function') {
116
+ const asarMsg = 'The service was stopped: spawn C:\\Users\\x\\AppData\\Local\\Programs\\myapp\\resources\\app.asar\\node_modules\\@esbuild\\win32-x64\\esbuild.exe ENOENT'
117
+ const hint = esbuildAsarSpawnHint(asarMsg)
118
+ chk(typeof hint === 'string' && /asarUnpack/.test(hint),
119
+ `an asar-packaged esbuild ENOENT message gets an asarUnpack hint (got ${JSON.stringify(hint)})`)
120
+
121
+ const syntaxMsg = 'Transform failed with 1 error:\nfile.js:1:1: ERROR: Unexpected "}"'
122
+ chk(esbuildAsarSpawnHint(syntaxMsg) === null,
123
+ `an unrelated esbuild syntax error gets no hint (got ${JSON.stringify(esbuildAsarSpawnHint(syntaxMsg))})`)
124
+
125
+ const oxcMsg = 'Cannot find native binding. Napi module could not be resolved.'
126
+ chk(esbuildAsarSpawnHint(oxcMsg) === null,
127
+ `an oxc native-binding message (oxcNativeBindingHint's own territory) gets no hint here (got ${JSON.stringify(esbuildAsarSpawnHint(oxcMsg))})`)
128
+ } else {
129
+ failed = true
130
+ console.error('❌ skipped esbuildAsarSpawnHint behavior checks (not exported)')
131
+ }
132
+
133
+ fs.rmSync(TMP, { recursive: true, force: true })
134
+ console.log(failed ? '\n❌ FAIL' : '\n✅ PASS: dead-esbuild-service classification, recycle-on-failure, retry-still-rejects, asar hint')
135
+ process.exit(failed ? 1 : 0)
package/src/pool-node.js CHANGED
@@ -38,7 +38,18 @@ const { Worker } = createRequire(import.meta.url)('node:worker_threads')
38
38
  // generous than the browser default: disk builds run whole real projects.
39
39
  const DEFAULT_SEND_TIMEOUT_MS = 120000
40
40
 
41
- const WORKER_DEATH_CODES = new Set(['compiler-worker-timeout', 'compiler-worker-crashed', 'compiler-worker-dead'])
41
+ // 'compiler-toolchain-dead' belongs here even though the worker THREAD is alive: the
42
+ // realm's esbuild service child process is gone, so the realm is just as unusable as a
43
+ // crashed worker — the same one-retry-on-fresh-workers policy applies.
44
+ const WORKER_DEATH_CODES = new Set(['compiler-worker-timeout', 'compiler-worker-crashed', 'compiler-worker-dead', 'compiler-toolchain-dead'])
45
+
46
+ // esbuild's node lib drives a spawned long-lived binary child (its "service"). When that
47
+ // child dies (spawn ENOENT in a packaged app, OOM kill, AV kill), esbuild reports every
48
+ // call with one of these two phrases — and the service NEVER restarts inside that realm,
49
+ // so the warm worker is permanently broken and must be recycled, not kept.
50
+ function isDeadToolchainServiceError(message) {
51
+ return /The service (was stopped|is no longer running)/.test(String(message))
52
+ }
42
53
 
43
54
  // Default idle window before the pool shrinks (terminates its resident stage workers to
44
55
  // release their memory — a warm worker set holds hundreds of MB of toolchain + compile
@@ -164,18 +175,33 @@ export function createNodeCompilerPool({
164
175
  if (err && err.code && !err.stage) err.stage = x.stage
165
176
  throw err
166
177
  })))
167
- for (const r of results) {
168
- if (!r || r.type === 'error') {
169
- const info = r && r.error
170
- const cause = (info && info.message) || 'unknown error'
171
- const hint = oxcNativeBindingHint(cause)
172
- const err = new Error(`[compiler] stage "${r && r.stage}" failed: ${cause}${hint ? ` — ${hint}` : ''}`)
173
- if (info && info.stack) err.stack = info.stack
174
- err.stage = r && r.stage
178
+ // Walk EVERY result before throwing: when more than one stage hit a dead toolchain
179
+ // service (logic and view both drive esbuild), each broken realm must be recycled
180
+ // now throwing on the first would leave the sibling's dead service warm, and the
181
+ // next build would fail on it all over again.
182
+ let firstErr = null
183
+ for (let i = 0; i < results.length; i++) {
184
+ const r = results[i]
185
+ if (r && r.type !== 'error') continue
186
+ const info = r && r.error
187
+ const cause = (info && info.message) || 'unknown error'
188
+ const hint = oxcNativeBindingHint(cause) || esbuildAsarSpawnHint(cause)
189
+ const err = new Error(`[compiler] stage "${r && r.stage}" failed: ${cause}${hint ? ` — ${hint}` : ''}`)
190
+ if (info && info.stack) err.stack = info.stack
191
+ err.stage = r && r.stage
192
+ if (isDeadToolchainServiceError(cause)) {
193
+ // The realm's toolchain service child is dead and never comes back — terminate
194
+ // the worker so the next attempt (the transparent retry, or the next build once
195
+ // the environment is healed) respawns a fresh realm with a fresh service.
196
+ // shrink() is safe here: settleAll above guarantees no request is in flight.
197
+ err.code = 'compiler-toolchain-dead'
198
+ workers[i].slot.shrink()
199
+ } else {
175
200
  err.code = 'compiler-stage-error' // worker-reported compile error — never retried
176
- throw err
177
201
  }
202
+ if (!firstErr) firstErr = err
178
203
  }
204
+ if (firstErr) throw firstErr
179
205
 
180
206
  // 3) Publish the staging dir to the caller's outputDir (dmcc-identical layout).
181
207
  publishToDist(outputDir, useAppIdDir)
@@ -234,6 +260,24 @@ export function createNodeCompilerPool({
234
260
  // user-facing compile-log lines a host (e.g. devkit/devtools' log panel) already scrapes.
235
261
  const STAGE_TITLES = { logic: '编译页面逻辑', view: '编译页面文件', style: '编译样式文件' }
236
262
 
263
+ /**
264
+ * Map a failure message to an actionable packaging hint when it is esbuild failing to
265
+ * spawn its native binary from inside an Electron app.asar archive. Electron patches
266
+ * child_process.execFile for asar paths but NOT child_process.spawn (which esbuild
267
+ * uses), so an in-archive binary path always ENOENTs at spawn even though fs sees the
268
+ * file — the raw message points at a path that plainly exists, which is why it needs
269
+ * a hint. Returns null for every other message.
270
+ * @param {string} message
271
+ * @returns {string | null}
272
+ */
273
+ export function esbuildAsarSpawnHint(message) {
274
+ const msg = String(message)
275
+ if (!/app\.asar/.test(msg) || !/esbuild/i.test(msg) || !/ENOENT/.test(msg)) return null
276
+ return 'esbuild 的原生二进制无法从 app.asar 内 spawn(Electron 只为 execFile 打 asar 补丁):'
277
+ + "打包配置需 asarUnpack '**/node_modules/esbuild/**' 与 '**/node_modules/@esbuild/**',"
278
+ + '并确保 ESBUILD_BINARY_PATH 指向 app.asar.unpacked 下的真实二进制(@dimina-kit/devkit 在 asar 内运行时会自动设置)'
279
+ }
280
+
237
281
  /**
238
282
  * Map a failure message to an actionable packaging hint when it is oxc-parser's
239
283
  * "missing runtime binding" error (thrown when NEITHER the platform-native