@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/README.md +4 -1
- package/dist/compile-core.browser.js +1211 -1212
- package/dist/pool.node.js +26 -10
- package/dist/stage-worker.browser.js +1211 -1212
- package/package.json +2 -1
- package/scripts/test-pool-toolchain-death.js +135 -0
- package/src/pool-node.js +54 -10
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dimina-kit/compiler",
|
|
3
|
-
"version": "0.0.1-dev.
|
|
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
|
-
|
|
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
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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
|