@dimina-kit/devkit 0.1.2-dev.20260703051110 → 0.1.2-dev.20260706064107
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 +16 -0
- package/dist/compile-worker-adopt.test.d.ts +2 -0
- package/dist/compile-worker-adopt.test.d.ts.map +1 -0
- package/dist/compile-worker-adopt.test.js +187 -0
- package/dist/compile-worker-entry-warmpool.test.d.ts +2 -0
- package/dist/compile-worker-entry-warmpool.test.d.ts.map +1 -0
- package/dist/compile-worker-entry-warmpool.test.js +110 -0
- package/dist/compile-worker-entry.d.ts +35 -5
- package/dist/compile-worker-entry.d.ts.map +1 -1
- package/dist/compile-worker-entry.js +45 -1
- package/dist/compile-worker-standby-hardening.test.d.ts +2 -0
- package/dist/compile-worker-standby-hardening.test.d.ts.map +1 -0
- package/dist/compile-worker-standby-hardening.test.js +212 -0
- package/dist/compile-worker-standby-integration.test.d.ts +2 -0
- package/dist/compile-worker-standby-integration.test.d.ts.map +1 -0
- package/dist/compile-worker-standby-integration.test.js +243 -0
- package/dist/compile-worker-standby.d.ts +60 -0
- package/dist/compile-worker-standby.d.ts.map +1 -0
- package/dist/compile-worker-standby.js +283 -0
- package/dist/compile-worker-standby.test.d.ts +2 -0
- package/dist/compile-worker-standby.test.d.ts.map +1 -0
- package/dist/compile-worker-standby.test.js +356 -0
- package/dist/compile-worker.d.ts +53 -0
- package/dist/compile-worker.d.ts.map +1 -1
- package/dist/compile-worker.js +30 -11
- package/dist/index.d.ts +21 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +74 -11
- package/dist/open-project-watcher-error-callback.test.d.ts +2 -0
- package/dist/open-project-watcher-error-callback.test.d.ts.map +1 -0
- package/dist/open-project-watcher-error-callback.test.js +191 -0
- package/package.json +3 -3
package/dist/compile-worker.d.ts
CHANGED
|
@@ -1,3 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parent-side orchestration of the forked compile worker.
|
|
3
|
+
*
|
|
4
|
+
* One long-lived worker per `openProject` session: the first compile and
|
|
5
|
+
* every watcher rebuild are `{ cmd: 'build' }` IPC messages to the SAME
|
|
6
|
+
* child, so the compiler's module/worker caches stay warm. The parent never
|
|
7
|
+
* calls `process.chdir` — the worker chdirs in its own process (the root
|
|
8
|
+
* motive of this architecture).
|
|
9
|
+
*
|
|
10
|
+
* Crash handling: an unexpected worker death ('exit', 'close' or a lone
|
|
11
|
+
* 'error' event — Node does NOT guarantee an 'exit' after 'error') rejects
|
|
12
|
+
* the in-flight build (so the rebuild scheduler settles instead of hanging)
|
|
13
|
+
* and the NEXT build lazily re-forks a fresh worker. The death handler is
|
|
14
|
+
* idempotent and generation-guarded: a dead child's late 'exit' can never
|
|
15
|
+
* settle a build that belongs to a fresh worker. `close()` kills, rejects
|
|
16
|
+
* the in-flight build itself, and resolves on the child's first death event
|
|
17
|
+
* ('exit'/'close', or a lone 'error') — it never re-forks
|
|
18
|
+
* (refill-on-graceful-close would wedge process teardown).
|
|
19
|
+
*/
|
|
20
|
+
import { type ChildProcess } from 'node:child_process';
|
|
1
21
|
import type { WorkerAppInfo, WorkerBuildOptions } from './compile-worker-entry.js';
|
|
2
22
|
export interface CompileLogEntry {
|
|
3
23
|
stream: 'stdout' | 'stderr';
|
|
@@ -6,6 +26,16 @@ export interface CompileLogEntry {
|
|
|
6
26
|
export interface CompileWorkerOptions {
|
|
7
27
|
/** Filtered dmcc log lines (already through `filterDmccLogLine`). */
|
|
8
28
|
onLog?: (entry: CompileLogEntry) => void;
|
|
29
|
+
/**
|
|
30
|
+
* An already-forked compile-worker-entry process to reuse for the first
|
|
31
|
+
* build (a warm-standby hand-off) instead of paying a fresh fork + compiler
|
|
32
|
+
* import. Must be an IPC fork with piped stdio — the same shape spawnWorker
|
|
33
|
+
* produces. Pure accelerator: an adoptee that is already dead (or dies
|
|
34
|
+
* before the first build) is simply skipped and the next build forks fresh.
|
|
35
|
+
* Once adopted, the process is owned by this worker exactly like a
|
|
36
|
+
* self-forked one — close() kills it, its death rejects in-flight builds.
|
|
37
|
+
*/
|
|
38
|
+
adopt?: ChildProcess;
|
|
9
39
|
}
|
|
10
40
|
export interface BuildRequest {
|
|
11
41
|
projectPath: string;
|
|
@@ -29,5 +59,28 @@ export interface CompileWorker {
|
|
|
29
59
|
*/
|
|
30
60
|
close: () => Promise<void>;
|
|
31
61
|
}
|
|
62
|
+
/**
|
|
63
|
+
* Resolve the fork target. In the published build this is the compiled
|
|
64
|
+
* `dist/compile-worker-entry.js` sibling; under vitest/dev the module runs
|
|
65
|
+
* from `src/`, where only the `.ts` source exists — the entry is a
|
|
66
|
+
* zero-dependency, erasable-syntax-only shell by design, so Node can run it
|
|
67
|
+
* with type stripping (default since 22.18; behind `--experimental-strip-types`
|
|
68
|
+
* on 22.6–22.17, which `workerExecArgv` passes for a `.ts` entry).
|
|
69
|
+
*
|
|
70
|
+
* Exported (with `workerExecArgv`) as the single authority on the fork shape:
|
|
71
|
+
* the warm-standby manager forks the SAME entry with the SAME execArgv, so a
|
|
72
|
+
* spare is indistinguishable from a worker this module forks itself.
|
|
73
|
+
*/
|
|
74
|
+
export declare function resolveWorkerEntry(): string;
|
|
75
|
+
/**
|
|
76
|
+
* execArgv for the fork. A compiled `.js` entry (the published build, engines
|
|
77
|
+
* `node >=20`) needs nothing. A `.ts` entry (vitest/dev only) needs Node type
|
|
78
|
+
* stripping: `--experimental-strip-types` exists since 22.6 — on older Node
|
|
79
|
+
* the child would die at startup with an opaque ERR_UNKNOWN_FILE_EXTENSION /
|
|
80
|
+
* bad-option crash, so fail HERE with an actionable message instead. The
|
|
81
|
+
* ExperimentalWarning is silenced because the child's stderr is the dmcc log
|
|
82
|
+
* transport. On ≥22.18 (stripping on by default) the flag is still accepted.
|
|
83
|
+
*/
|
|
84
|
+
export declare function workerExecArgv(entry: string): string[];
|
|
32
85
|
export declare function createCompileWorker(opts?: CompileWorkerOptions): CompileWorker;
|
|
33
86
|
//# sourceMappingURL=compile-worker.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"compile-worker.d.ts","sourceRoot":"","sources":["../src/compile-worker.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"compile-worker.d.ts","sourceRoot":"","sources":["../src/compile-worker.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AACH,OAAO,EAAQ,KAAK,YAAY,EAAE,MAAM,oBAAoB,CAAA;AAM5D,OAAO,KAAK,EAAE,aAAa,EAAE,kBAAkB,EAAuB,MAAM,2BAA2B,CAAA;AAIvG,MAAM,WAAW,eAAe;IAC/B,MAAM,EAAE,QAAQ,GAAG,QAAQ,CAAA;IAC3B,IAAI,EAAE,MAAM,CAAA;CACZ;AAED,MAAM,WAAW,oBAAoB;IACpC,qEAAqE;IACrE,KAAK,CAAC,EAAE,CAAC,KAAK,EAAE,eAAe,KAAK,IAAI,CAAA;IACxC;;;;;;;;OAQG;IACH,KAAK,CAAC,EAAE,YAAY,CAAA;CACpB;AAED,MAAM,WAAW,YAAY;IAC5B,WAAW,EAAE,MAAM,CAAA;IACnB,SAAS,EAAE,MAAM,CAAA;IACjB,OAAO,EAAE,kBAAkB,CAAA;CAC3B;AAED,MAAM,WAAW,aAAa;IAC7B;;;;;OAKG;IACH,KAAK,EAAE,CAAC,GAAG,EAAE,YAAY,KAAK,OAAO,CAAC,aAAa,GAAG,IAAI,CAAC,CAAA;IAC3D;;;;;;OAMG;IACH,KAAK,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAA;CAC1B;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,kBAAkB,IAAI,MAAM,CAI3C;AAED;;;;;;;;GAQG;AACH,wBAAgB,cAAc,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,EAAE,CAWtD;AAED,wBAAgB,mBAAmB,CAAC,IAAI,GAAE,oBAAyB,GAAG,aAAa,CA8LlF"}
|
package/dist/compile-worker.js
CHANGED
|
@@ -30,8 +30,12 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
|
30
30
|
* zero-dependency, erasable-syntax-only shell by design, so Node can run it
|
|
31
31
|
* with type stripping (default since 22.18; behind `--experimental-strip-types`
|
|
32
32
|
* on 22.6–22.17, which `workerExecArgv` passes for a `.ts` entry).
|
|
33
|
+
*
|
|
34
|
+
* Exported (with `workerExecArgv`) as the single authority on the fork shape:
|
|
35
|
+
* the warm-standby manager forks the SAME entry with the SAME execArgv, so a
|
|
36
|
+
* spare is indistinguishable from a worker this module forks itself.
|
|
33
37
|
*/
|
|
34
|
-
function resolveWorkerEntry() {
|
|
38
|
+
export function resolveWorkerEntry() {
|
|
35
39
|
const js = path.join(__dirname, 'compile-worker-entry.js');
|
|
36
40
|
if (fs.existsSync(js))
|
|
37
41
|
return js;
|
|
@@ -46,7 +50,7 @@ function resolveWorkerEntry() {
|
|
|
46
50
|
* ExperimentalWarning is silenced because the child's stderr is the dmcc log
|
|
47
51
|
* transport. On ≥22.18 (stripping on by default) the flag is still accepted.
|
|
48
52
|
*/
|
|
49
|
-
function workerExecArgv(entry) {
|
|
53
|
+
export function workerExecArgv(entry) {
|
|
50
54
|
if (!entry.endsWith('.ts'))
|
|
51
55
|
return [];
|
|
52
56
|
const [major = 0, minor = 0] = process.versions.node.split('.').map(Number);
|
|
@@ -112,15 +116,10 @@ export function createCompileWorker(opts = {}) {
|
|
|
112
116
|
stream.on('end', flush);
|
|
113
117
|
stream.on('close', flush);
|
|
114
118
|
}
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
silent: true,
|
|
120
|
-
// Explicit execArgv, NOT the parent's (vitest/electron loaders would
|
|
121
|
-
// leak into a plain Node child).
|
|
122
|
-
execArgv: workerExecArgv(entry),
|
|
123
|
-
});
|
|
119
|
+
// Attach the log pipes, result routing and death handling to a worker
|
|
120
|
+
// process — the SAME wiring whether the process was self-forked or adopted
|
|
121
|
+
// from a warm standby (an inline copy for one of the two would drift).
|
|
122
|
+
function wireWorker(worker) {
|
|
124
123
|
attachLineReader(worker.stdout, 'stdout');
|
|
125
124
|
attachLineReader(worker.stderr, 'stderr');
|
|
126
125
|
// Shared, idempotent death handler for 'exit' / 'close' / 'error': the
|
|
@@ -177,6 +176,26 @@ export function createCompileWorker(opts = {}) {
|
|
|
177
176
|
worker.on('error', (err) => settleDeath(`compile worker errored: ${err.message}`));
|
|
178
177
|
return worker;
|
|
179
178
|
}
|
|
179
|
+
function spawnWorker() {
|
|
180
|
+
const entry = resolveWorkerEntry();
|
|
181
|
+
return wireWorker(fork(entry, [], {
|
|
182
|
+
// Pipe stdout/stderr back to the parent — the dmcc log transport.
|
|
183
|
+
silent: true,
|
|
184
|
+
// Explicit execArgv, NOT the parent's (vitest/electron loaders would
|
|
185
|
+
// leak into a plain Node child).
|
|
186
|
+
execArgv: workerExecArgv(entry),
|
|
187
|
+
}));
|
|
188
|
+
}
|
|
189
|
+
// Adopt the warm-standby hand-off, if any, at creation time — wiring
|
|
190
|
+
// immediately (not at the first build) so a spare that dies in the gap is
|
|
191
|
+
// handled by the normal death path (child cleared → next build re-forks)
|
|
192
|
+
// instead of being discovered as a broken pipe mid-build. An adoptee that
|
|
193
|
+
// is ALREADY dead is skipped outright: its death events fired before any
|
|
194
|
+
// listener could attach, so wiring it would leave a permanently wedged
|
|
195
|
+
// child slot.
|
|
196
|
+
if (opts.adopt && opts.adopt.exitCode === null && opts.adopt.signalCode === null && opts.adopt.connected) {
|
|
197
|
+
child = wireWorker(opts.adopt);
|
|
198
|
+
}
|
|
180
199
|
function runBuild(req) {
|
|
181
200
|
if (closed) {
|
|
182
201
|
return Promise.reject(new Error('compile worker is closed'));
|
package/dist/index.d.ts
CHANGED
|
@@ -1,9 +1,21 @@
|
|
|
1
1
|
import type { CompileLogEntry } from './compile-worker.js';
|
|
2
|
+
import type { CompileWorkerStandby, CompileWorkerStandbyOptions } from './compile-worker-standby.js';
|
|
2
3
|
export { createRebuildScheduler } from './rebuild-scheduler.js';
|
|
3
4
|
export type { RebuildScheduler } from './rebuild-scheduler.js';
|
|
4
5
|
export { filterDmccLogLine } from './compile-log.js';
|
|
5
6
|
export { createCompileWorker } from './compile-worker.js';
|
|
6
7
|
export type { CompileLogEntry, CompileWorker } from './compile-worker.js';
|
|
8
|
+
export { createCompileWorkerStandby } from './compile-worker-standby.js';
|
|
9
|
+
export type { CompileWorkerStandby, CompileWorkerStandbyOptions, StandbyEvent, StandbyState, } from './compile-worker-standby.js';
|
|
10
|
+
/**
|
|
11
|
+
* Turn on the warm-standby accelerator and start warming the first spare
|
|
12
|
+
* immediately. Idempotent while the returned manager is live — repeat calls
|
|
13
|
+
* return the SAME manager (their opts are ignored). After `dispose()` (host
|
|
14
|
+
* shutdown, or a test tearing down) a new call builds a fresh manager. The
|
|
15
|
+
* caller owns disposal; a disposed manager makes every openProject fall back
|
|
16
|
+
* to cold forks and never spawns again.
|
|
17
|
+
*/
|
|
18
|
+
export declare function enableCompileWorkerStandby(opts?: CompileWorkerStandbyOptions): CompileWorkerStandby;
|
|
7
19
|
export interface AppInfo {
|
|
8
20
|
appId: string;
|
|
9
21
|
name: string;
|
|
@@ -41,6 +53,14 @@ export interface OpenProjectOptions {
|
|
|
41
53
|
* worker's piped stdout/stderr, tagged with their source stream.
|
|
42
54
|
*/
|
|
43
55
|
onLog?: (entry: CompileLogEntry) => void;
|
|
56
|
+
/**
|
|
57
|
+
* Fired when the file watcher emits `'error'` (EMFILE, permission loss, …)
|
|
58
|
+
* AFTER its initial scan already resolved `ready` — i.e. the session is
|
|
59
|
+
* live and auto-rebuild-on-save has silently stopped working. A pre-ready
|
|
60
|
+
* error instead rejects `openProject` itself (see `createProjectWatcher`),
|
|
61
|
+
* so this only ever fires once per session, at most.
|
|
62
|
+
*/
|
|
63
|
+
onWatcherError?: (err: unknown) => void;
|
|
44
64
|
}
|
|
45
65
|
export declare function openProject(opts: OpenProjectOptions): Promise<ProjectSession>;
|
|
46
66
|
/**
|
|
@@ -49,7 +69,7 @@ export declare function openProject(opts: OpenProjectOptions): Promise<ProjectSe
|
|
|
49
69
|
* watcher and whose `ready` resolves once the initial scan is complete (only
|
|
50
70
|
* changes made after that point are guaranteed to surface).
|
|
51
71
|
*/
|
|
52
|
-
export declare function createProjectWatcher(projectPath: string, onChange: () => void | Promise<void
|
|
72
|
+
export declare function createProjectWatcher(projectPath: string, onChange: () => void | Promise<void>, onWatcherError?: (err: unknown) => void): {
|
|
53
73
|
close: () => Promise<void>;
|
|
54
74
|
ready: Promise<void>;
|
|
55
75
|
};
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAUA,OAAO,KAAK,EAAgB,eAAe,EAAiB,MAAM,qBAAqB,CAAA;AAEvF,OAAO,EAAE,sBAAsB,EAAE,MAAM,wBAAwB,CAAA;AAC/D,YAAY,EAAE,gBAAgB,EAAE,MAAM,wBAAwB,CAAA;AAC9D,OAAO,EAAE,iBAAiB,EAAE,MAAM,kBAAkB,CAAA;AACpD,OAAO,EAAE,mBAAmB,EAAE,MAAM,qBAAqB,CAAA;AACzD,YAAY,EAAE,eAAe,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAA;
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAUA,OAAO,KAAK,EAAgB,eAAe,EAAiB,MAAM,qBAAqB,CAAA;AAEvF,OAAO,KAAK,EAAE,oBAAoB,EAAE,2BAA2B,EAAE,MAAM,6BAA6B,CAAA;AAEpG,OAAO,EAAE,sBAAsB,EAAE,MAAM,wBAAwB,CAAA;AAC/D,YAAY,EAAE,gBAAgB,EAAE,MAAM,wBAAwB,CAAA;AAC9D,OAAO,EAAE,iBAAiB,EAAE,MAAM,kBAAkB,CAAA;AACpD,OAAO,EAAE,mBAAmB,EAAE,MAAM,qBAAqB,CAAA;AACzD,YAAY,EAAE,eAAe,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAA;AACzE,OAAO,EAAE,0BAA0B,EAAE,MAAM,6BAA6B,CAAA;AACxE,YAAY,EACX,oBAAoB,EACpB,2BAA2B,EAC3B,YAAY,EACZ,YAAY,GACZ,MAAM,6BAA6B,CAAA;AAYpC;;;;;;;GAOG;AACH,wBAAgB,0BAA0B,CACzC,IAAI,CAAC,EAAE,2BAA2B,GAChC,oBAAoB,CAKtB;AAqDD,MAAM,WAAW,OAAO;IACvB,KAAK,EAAE,MAAM,CAAA;IACb,IAAI,EAAE,MAAM,CAAA;IACZ,IAAI,EAAE,MAAM,CAAA;CACZ;AAED,MAAM,WAAW,cAAc;IAC9B,OAAO,EAAE,OAAO,CAAA;IAChB,IAAI,EAAE,MAAM,CAAA;IACZ,KAAK,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAA;CAC1B;AAED,MAAM,WAAW,kBAAkB;IAClC,WAAW,EAAE,MAAM,CAAA;IACnB,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,SAAS,CAAC,EAAE,OAAO,CAAA;IACnB;;;;OAIG;IACH,SAAS,CAAC,EAAE;QAAE,QAAQ,CAAC,EAAE,MAAM,EAAE,CAAC;QAAC,KAAK,CAAC,EAAE,MAAM,EAAE,CAAC;QAAC,UAAU,CAAC,EAAE,MAAM,EAAE,CAAA;KAAE,CAAA;IAC5E,YAAY,CAAC,EAAE,MAAM,CAAA;IACrB,YAAY,CAAC,EAAE,MAAM,CAAA;IACrB,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,oFAAoF;IACpF,KAAK,CAAC,EAAE,OAAO,CAAA;IACf,SAAS,CAAC,EAAE,MAAM,IAAI,CAAA;IACtB,YAAY,CAAC,EAAE,CAAC,GAAG,EAAE,OAAO,KAAK,IAAI,CAAA;IACrC;;;;OAIG;IACH,KAAK,CAAC,EAAE,CAAC,KAAK,EAAE,eAAe,KAAK,IAAI,CAAA;IACxC;;;;;;OAMG;IACH,cAAc,CAAC,EAAE,CAAC,GAAG,EAAE,OAAO,KAAK,IAAI,CAAA;CACvC;AAuFD,wBAAsB,WAAW,CAAC,IAAI,EAAE,kBAAkB,GAAG,OAAO,CAAC,cAAc,CAAC,CAoJnF;AAED;;;;;GAKG;AACH,wBAAgB,oBAAoB,CACnC,WAAW,EAAE,MAAM,EACnB,QAAQ,EAAE,MAAM,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,EACpC,cAAc,CAAC,EAAE,CAAC,GAAG,EAAE,OAAO,KAAK,IAAI,GACrC;IAAE,KAAK,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;IAAC,KAAK,EAAE,OAAO,CAAC,IAAI,CAAC,CAAA;CAAE,CA2DtD"}
|
package/dist/index.js
CHANGED
|
@@ -8,9 +8,46 @@ import { createRequire } from 'node:module';
|
|
|
8
8
|
import chokidar from 'chokidar';
|
|
9
9
|
import { createRebuildScheduler } from './rebuild-scheduler.js';
|
|
10
10
|
import { createCompileWorker } from './compile-worker.js';
|
|
11
|
+
import { createCompileWorkerStandby } from './compile-worker-standby.js';
|
|
11
12
|
export { createRebuildScheduler } from './rebuild-scheduler.js';
|
|
12
13
|
export { filterDmccLogLine } from './compile-log.js';
|
|
13
14
|
export { createCompileWorker } from './compile-worker.js';
|
|
15
|
+
export { createCompileWorkerStandby } from './compile-worker-standby.js';
|
|
16
|
+
// ── warm standby (opt-in) ──────────────────────────────────────────────────
|
|
17
|
+
//
|
|
18
|
+
// One module-level spare compile worker shared by every `openProject` in this
|
|
19
|
+
// process. While no project is open the spare sits forked + prewarmed
|
|
20
|
+
// (project-agnostic — it never chdirs, see compile-worker-entry); openProject
|
|
21
|
+
// adopts it for the first compile and each session close refills it. Purely
|
|
22
|
+
// additive: hosts that never call `enableCompileWorkerStandby` get exactly the
|
|
23
|
+
// cold-fork behavior they had before.
|
|
24
|
+
let standbyManager = null;
|
|
25
|
+
/**
|
|
26
|
+
* Turn on the warm-standby accelerator and start warming the first spare
|
|
27
|
+
* immediately. Idempotent while the returned manager is live — repeat calls
|
|
28
|
+
* return the SAME manager (their opts are ignored). After `dispose()` (host
|
|
29
|
+
* shutdown, or a test tearing down) a new call builds a fresh manager. The
|
|
30
|
+
* caller owns disposal; a disposed manager makes every openProject fall back
|
|
31
|
+
* to cold forks and never spawns again.
|
|
32
|
+
*/
|
|
33
|
+
export function enableCompileWorkerStandby(opts) {
|
|
34
|
+
if (standbyManager && standbyManager.state !== 'disposed')
|
|
35
|
+
return standbyManager;
|
|
36
|
+
standbyManager = createCompileWorkerStandby(opts);
|
|
37
|
+
standbyManager.ensure();
|
|
38
|
+
return standbyManager;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Refill the spare after a compile worker has been consumed and fully closed.
|
|
42
|
+
* Fire-and-forget BY DESIGN: the fork happens asynchronously inside the
|
|
43
|
+
* manager, so a session close never waits on (and can never be wedged by)
|
|
44
|
+
* standby work — the refill-on-graceful-close deadlock class stays
|
|
45
|
+
* structurally impossible. No-ops when the standby is off, already warming,
|
|
46
|
+
* or disposed.
|
|
47
|
+
*/
|
|
48
|
+
function refillStandby() {
|
|
49
|
+
standbyManager?.ensure();
|
|
50
|
+
}
|
|
14
51
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
15
52
|
const require = createRequire(import.meta.url);
|
|
16
53
|
// esbuild's native binary is shipped inside node_modules, but when packaged
|
|
@@ -114,7 +151,7 @@ async function cleanupFailedOpen(watcher, compileWorker, server) {
|
|
|
114
151
|
}
|
|
115
152
|
}
|
|
116
153
|
export async function openProject(opts) {
|
|
117
|
-
const { projectPath: rawProjectPath, port = 0, sourcemap = false, fileTypes, simulatorDir, containerDir: overrideContainerDir, outputDir, watch = true, onRebuild, onBuildError, onLog, } = opts;
|
|
154
|
+
const { projectPath: rawProjectPath, port = 0, sourcemap = false, fileTypes, simulatorDir, containerDir: overrideContainerDir, outputDir, watch = true, onRebuild, onBuildError, onLog, onWatcherError, } = opts;
|
|
118
155
|
const projectPath = path.resolve(rawProjectPath);
|
|
119
156
|
const buildOptions = { sourcemap, fileTypes };
|
|
120
157
|
const resolvedPort = port === 0 ? await getRandomPort() : port;
|
|
@@ -125,8 +162,12 @@ export async function openProject(opts) {
|
|
|
125
162
|
// Compilation runs in a long-lived forked worker — the worker chdirs in
|
|
126
163
|
// its OWN process, so this (host) process never mutates its cwd, and dmcc
|
|
127
164
|
// terminal output arrives on the worker's piped streams (delivered to
|
|
128
|
-
// `onLog` line-by-line after `filterDmccLogLine`).
|
|
129
|
-
|
|
165
|
+
// `onLog` line-by-line after `filterDmccLogLine`). When the warm standby is
|
|
166
|
+
// enabled and holds a healthy spare, adopt it: the first compile then runs
|
|
167
|
+
// on an already-forked, toolchain-warm process. adopt() degrades to null on
|
|
168
|
+
// every failure path, which lands in the normal cold-fork behavior.
|
|
169
|
+
const adopted = standbyManager ? await standbyManager.adopt() : null;
|
|
170
|
+
const compileWorker = createCompileWorker({ onLog, ...(adopted ? { adopt: adopted } : {}) });
|
|
130
171
|
const buildRequest = {
|
|
131
172
|
projectPath,
|
|
132
173
|
outputDir: resolvedOutputDir,
|
|
@@ -138,6 +179,9 @@ export async function openProject(opts) {
|
|
|
138
179
|
}
|
|
139
180
|
catch (err) {
|
|
140
181
|
await compileWorker.close();
|
|
182
|
+
// A failed open consumed the spare too — refill (fire-and-forget) so the
|
|
183
|
+
// NEXT open attempt still starts warm.
|
|
184
|
+
refillStandby();
|
|
141
185
|
throw err;
|
|
142
186
|
}
|
|
143
187
|
// When the compiler is racing (e.g. opening a project that was just
|
|
@@ -194,7 +238,9 @@ export async function openProject(opts) {
|
|
|
194
238
|
// a build is in flight is never dropped: it coalesces into exactly one
|
|
195
239
|
// trailing rebuild once the current run settles.
|
|
196
240
|
const rebuildScheduler = createRebuildScheduler(() => runRebuild(compileWorker, buildRequest, sessionApps, () => reload, onRebuild, onBuildError));
|
|
197
|
-
watcher = watch
|
|
241
|
+
watcher = watch
|
|
242
|
+
? createProjectWatcher(projectPath, () => rebuildScheduler.schedule(), onWatcherError)
|
|
243
|
+
: null;
|
|
198
244
|
// Don't resolve until the watcher's initial scan is done: a save landing
|
|
199
245
|
// in the gap between `openProject` resolving and chokidar going live
|
|
200
246
|
// (fsevents stream not yet active on macOS) would otherwise be silently
|
|
@@ -205,6 +251,7 @@ export async function openProject(opts) {
|
|
|
205
251
|
}
|
|
206
252
|
catch (err) {
|
|
207
253
|
await cleanupFailedOpen(watcher, compileWorker, server);
|
|
254
|
+
refillStandby();
|
|
208
255
|
throw err;
|
|
209
256
|
}
|
|
210
257
|
// `server` is always assigned when the try block completes without throwing
|
|
@@ -218,9 +265,13 @@ export async function openProject(opts) {
|
|
|
218
265
|
close: async () => {
|
|
219
266
|
await watcher?.close();
|
|
220
267
|
// Kill the long-lived compile worker and WAIT for the child to be
|
|
221
|
-
// actually dead —
|
|
222
|
-
// refill here would wedge process teardown).
|
|
268
|
+
// actually dead — THIS worker is never re-forked on a graceful close
|
|
269
|
+
// (an awaited refill here would wedge process teardown).
|
|
223
270
|
await compileWorker.close();
|
|
271
|
+
// The standby refill is different: fire-and-forget into the manager
|
|
272
|
+
// (nothing here waits on it), sequenced after the old worker's death
|
|
273
|
+
// so spare + dying worker never hold memory simultaneously.
|
|
274
|
+
refillStandby();
|
|
224
275
|
liveServer.closeAllConnections?.();
|
|
225
276
|
await new Promise(resolve => liveServer.close(() => resolve()));
|
|
226
277
|
},
|
|
@@ -232,7 +283,7 @@ export async function openProject(opts) {
|
|
|
232
283
|
* watcher and whose `ready` resolves once the initial scan is complete (only
|
|
233
284
|
* changes made after that point are guaranteed to surface).
|
|
234
285
|
*/
|
|
235
|
-
export function createProjectWatcher(projectPath, onChange) {
|
|
286
|
+
export function createProjectWatcher(projectPath, onChange, onWatcherError) {
|
|
236
287
|
// Ignore dotfiles/dot-directories that live *inside* the project (.git,
|
|
237
288
|
// .DS_Store, editor scratch, etc.) without nuking the whole watch when the
|
|
238
289
|
// project itself sits under a dotted ancestor path. A regex like
|
|
@@ -267,12 +318,24 @@ export function createProjectWatcher(projectPath, onChange) {
|
|
|
267
318
|
// A watcher 'error' before the initial scan completes (EMFILE, permission
|
|
268
319
|
// loss, …) must REJECT `ready`: resolving only on 'ready' would leave
|
|
269
320
|
// `await watcher.ready` — and the whole openProject — hung forever. The
|
|
270
|
-
// listener also doubles as the EventEmitter unhandled-'error' guard.
|
|
271
|
-
//
|
|
321
|
+
// listener also doubles as the EventEmitter unhandled-'error' guard. Once
|
|
322
|
+
// `ready` has settled, the reject above is a no-op — that's the live-session
|
|
323
|
+
// case (watcher died mid-session, not at open time) and is instead surfaced
|
|
324
|
+
// through `onWatcherError`, the only remaining signal that auto-rebuild has
|
|
325
|
+
// silently stopped.
|
|
326
|
+
let readySettled = false;
|
|
272
327
|
const ready = new Promise((resolve, reject) => {
|
|
273
|
-
watcher.once('ready', () =>
|
|
328
|
+
watcher.once('ready', () => {
|
|
329
|
+
readySettled = true;
|
|
330
|
+
resolve();
|
|
331
|
+
});
|
|
274
332
|
watcher.on('error', (err) => {
|
|
275
|
-
|
|
333
|
+
if (!readySettled) {
|
|
334
|
+
readySettled = true;
|
|
335
|
+
reject(err instanceof Error ? err : new Error(String(err)));
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
onWatcherError?.(err);
|
|
276
339
|
});
|
|
277
340
|
});
|
|
278
341
|
return {
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"open-project-watcher-error-callback.test.d.ts","sourceRoot":"","sources":["../src/open-project-watcher-error-callback.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { EventEmitter } from 'node:events';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { PassThrough } from 'node:stream';
|
|
6
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
7
|
+
import * as devkit from './index.js';
|
|
8
|
+
/**
|
|
9
|
+
* Contract: `openProject` gains an `onWatcherError?: (err: unknown) => void`
|
|
10
|
+
* option. A watcher 'error' BEFORE the initial scan completes must keep
|
|
11
|
+
* rejecting `openProject` exactly as today (see open-project-cleanup.test.ts)
|
|
12
|
+
* — `onWatcherError` must NOT be called for that pre-ready path, so callers
|
|
13
|
+
* don't get a double signal for the same failure.
|
|
14
|
+
*
|
|
15
|
+
* A watcher 'error' AFTER 'ready' (EMFILE, permission loss mid-session, …) is
|
|
16
|
+
* TODAY silently dropped: `createProjectWatcher`'s `watcher.on('error', ...)`
|
|
17
|
+
* only rejects the (long-since-settled) `ready` promise, a no-op once ready
|
|
18
|
+
* has already resolved. The project keeps "running" with a dead watcher and
|
|
19
|
+
* nothing downstream ever finds out — a save after that point compiles
|
|
20
|
+
* nothing, forever, with no diagnostic. The fix: `openProject` must forward a
|
|
21
|
+
* post-ready watcher 'error' to `onWatcherError` so a caller (devtools'
|
|
22
|
+
* workspace-service) can surface "auto-rebuild stopped working" instead of
|
|
23
|
+
* silently going stale.
|
|
24
|
+
*
|
|
25
|
+
* Harness: verbatim copy of open-project-cleanup.test.ts's fake-fork +
|
|
26
|
+
* fake-watcher setup (fork/fe/chokidar all mocked so the failure point is
|
|
27
|
+
* injectable), plus a FakeWatcher that can fire 'error' AFTER 'ready'.
|
|
28
|
+
*/
|
|
29
|
+
const mocks = vi.hoisted(() => ({
|
|
30
|
+
fork: vi.fn(),
|
|
31
|
+
feStart: vi.fn(),
|
|
32
|
+
watch: vi.fn(),
|
|
33
|
+
}));
|
|
34
|
+
vi.mock('node:child_process', async (importOriginal) => {
|
|
35
|
+
const actual = await importOriginal();
|
|
36
|
+
return { ...actual, fork: mocks.fork, default: { ...actual, fork: mocks.fork } };
|
|
37
|
+
});
|
|
38
|
+
vi.mock('child_process', async (importOriginal) => {
|
|
39
|
+
const actual = await importOriginal();
|
|
40
|
+
return { ...actual, fork: mocks.fork, default: { ...actual, fork: mocks.fork } };
|
|
41
|
+
});
|
|
42
|
+
vi.mock('../fe/index.js', () => ({ start: mocks.feStart }));
|
|
43
|
+
vi.mock('chokidar', () => ({
|
|
44
|
+
default: { watch: mocks.watch },
|
|
45
|
+
watch: mocks.watch,
|
|
46
|
+
}));
|
|
47
|
+
class FakeChild extends EventEmitter {
|
|
48
|
+
stdout = new PassThrough();
|
|
49
|
+
stderr = new PassThrough();
|
|
50
|
+
connected = true;
|
|
51
|
+
pid = 4343;
|
|
52
|
+
send = vi.fn((msg) => {
|
|
53
|
+
const m = msg;
|
|
54
|
+
if (m && m.cmd === 'build') {
|
|
55
|
+
queueMicrotask(() => {
|
|
56
|
+
if (!this.connected)
|
|
57
|
+
return;
|
|
58
|
+
this.emit('message', {
|
|
59
|
+
type: 'result',
|
|
60
|
+
appInfo: { appId: 'watcher_err_app_001', name: 'watcher-err-app', path: String(m.projectPath ?? '') },
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
return true;
|
|
65
|
+
});
|
|
66
|
+
kill = vi.fn(() => {
|
|
67
|
+
this.connected = false;
|
|
68
|
+
queueMicrotask(() => this.emit('exit', null, 'SIGTERM'));
|
|
69
|
+
return true;
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
class FakeWatcher extends EventEmitter {
|
|
73
|
+
close = vi.fn(async () => { });
|
|
74
|
+
}
|
|
75
|
+
function makeFakeFe() {
|
|
76
|
+
const server = {
|
|
77
|
+
close: vi.fn((cb) => {
|
|
78
|
+
cb?.();
|
|
79
|
+
return server;
|
|
80
|
+
}),
|
|
81
|
+
closeAllConnections: vi.fn(),
|
|
82
|
+
};
|
|
83
|
+
return { server, reload: vi.fn() };
|
|
84
|
+
}
|
|
85
|
+
const children = [];
|
|
86
|
+
const watchers = [];
|
|
87
|
+
const feInstances = [];
|
|
88
|
+
const cleanupRoots = [];
|
|
89
|
+
beforeEach(() => {
|
|
90
|
+
children.length = 0;
|
|
91
|
+
watchers.length = 0;
|
|
92
|
+
feInstances.length = 0;
|
|
93
|
+
mocks.fork.mockReset();
|
|
94
|
+
mocks.fork.mockImplementation(() => {
|
|
95
|
+
const child = new FakeChild();
|
|
96
|
+
children.push(child);
|
|
97
|
+
return child;
|
|
98
|
+
});
|
|
99
|
+
mocks.feStart.mockReset();
|
|
100
|
+
mocks.feStart.mockImplementation(async () => {
|
|
101
|
+
const fe = makeFakeFe();
|
|
102
|
+
feInstances.push(fe);
|
|
103
|
+
return fe;
|
|
104
|
+
});
|
|
105
|
+
mocks.watch.mockReset();
|
|
106
|
+
});
|
|
107
|
+
afterEach(() => {
|
|
108
|
+
for (const root of cleanupRoots.splice(0)) {
|
|
109
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
function makeFixture() {
|
|
113
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'devkit-watcher-err-'));
|
|
114
|
+
cleanupRoots.push(root);
|
|
115
|
+
fs.writeFileSync(path.join(root, 'project.config.json'), JSON.stringify({ appid: 'watcher_err_app_001', projectname: 'watcher-err-app' }));
|
|
116
|
+
return root;
|
|
117
|
+
}
|
|
118
|
+
function sleep(ms) {
|
|
119
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
120
|
+
}
|
|
121
|
+
describe('openProject onWatcherError — post-ready watcher errors are surfaced, pre-ready path is unchanged', () => {
|
|
122
|
+
it('does NOT call onWatcherError for a watcher error BEFORE ready (openProject still rejects, same as today)', async () => {
|
|
123
|
+
const root = makeFixture();
|
|
124
|
+
mocks.watch.mockImplementation(() => {
|
|
125
|
+
const watcher = new FakeWatcher();
|
|
126
|
+
watchers.push(watcher);
|
|
127
|
+
setTimeout(() => {
|
|
128
|
+
try {
|
|
129
|
+
watcher.emit('error', new Error('EMFILE: too many open files, watch'));
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
// no 'error' listener path — matches open-project-cleanup.test.ts
|
|
133
|
+
}
|
|
134
|
+
}, 10);
|
|
135
|
+
return watcher;
|
|
136
|
+
});
|
|
137
|
+
const onWatcherError = vi.fn();
|
|
138
|
+
await expect(devkit.openProject({
|
|
139
|
+
projectPath: root,
|
|
140
|
+
watch: true,
|
|
141
|
+
outputDir: path.join(root, '.out'),
|
|
142
|
+
onWatcherError,
|
|
143
|
+
})).rejects.toThrow();
|
|
144
|
+
expect(onWatcherError, 'a pre-ready watcher error must keep rejecting openProject WITHOUT also invoking onWatcherError — '
|
|
145
|
+
+ 'the caller already gets the failure via the rejected promise').not.toHaveBeenCalled();
|
|
146
|
+
}, 15_000);
|
|
147
|
+
it('calls onWatcherError with the error when the watcher fails AFTER ready — openProject has already resolved by then', async () => {
|
|
148
|
+
const root = makeFixture();
|
|
149
|
+
let capturedWatcher = null;
|
|
150
|
+
mocks.watch.mockImplementation(() => {
|
|
151
|
+
const watcher = new FakeWatcher();
|
|
152
|
+
watchers.push(watcher);
|
|
153
|
+
capturedWatcher = watcher;
|
|
154
|
+
queueMicrotask(() => watcher.emit('ready'));
|
|
155
|
+
return watcher;
|
|
156
|
+
});
|
|
157
|
+
const onWatcherError = vi.fn();
|
|
158
|
+
const session = await devkit.openProject({
|
|
159
|
+
projectPath: root,
|
|
160
|
+
watch: true,
|
|
161
|
+
outputDir: path.join(root, '.out'),
|
|
162
|
+
onWatcherError,
|
|
163
|
+
});
|
|
164
|
+
const postReadyError = new Error('EMFILE: too many open files, watch (post-ready)');
|
|
165
|
+
capturedWatcher.emit('error', postReadyError);
|
|
166
|
+
await sleep(20);
|
|
167
|
+
expect(onWatcherError, 'today nothing listens for a post-ready watcher error beyond the already-settled ready promise — '
|
|
168
|
+
+ 'the project silently stops auto-rebuilding forever with no signal').toHaveBeenCalledWith(postReadyError);
|
|
169
|
+
await session.close();
|
|
170
|
+
}, 15_000);
|
|
171
|
+
it('a post-ready watcher error does not crash the process (no unhandled "error" event) even without onWatcherError supplied', async () => {
|
|
172
|
+
const root = makeFixture();
|
|
173
|
+
let capturedWatcher = null;
|
|
174
|
+
mocks.watch.mockImplementation(() => {
|
|
175
|
+
const watcher = new FakeWatcher();
|
|
176
|
+
watchers.push(watcher);
|
|
177
|
+
capturedWatcher = watcher;
|
|
178
|
+
queueMicrotask(() => watcher.emit('ready'));
|
|
179
|
+
return watcher;
|
|
180
|
+
});
|
|
181
|
+
const session = await devkit.openProject({
|
|
182
|
+
projectPath: root,
|
|
183
|
+
watch: true,
|
|
184
|
+
outputDir: path.join(root, '.out'),
|
|
185
|
+
});
|
|
186
|
+
expect(() => {
|
|
187
|
+
capturedWatcher.emit('error', new Error('EMFILE (no onWatcherError supplied)'));
|
|
188
|
+
}).not.toThrow();
|
|
189
|
+
await session.close();
|
|
190
|
+
}, 15_000);
|
|
191
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dimina-kit/devkit",
|
|
3
|
-
"version": "0.1.2-dev.
|
|
3
|
+
"version": "0.1.2-dev.20260706064107",
|
|
4
4
|
"description": "Development toolkit for Dimina mini-apps with H5 container preview",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"dimina",
|
|
@@ -62,7 +62,7 @@
|
|
|
62
62
|
"cors": "^2.8.6",
|
|
63
63
|
"express": "^5.2.1",
|
|
64
64
|
"open": "^11.0.0",
|
|
65
|
-
"@dimina-kit/compiler": "0.0.1-dev.
|
|
65
|
+
"@dimina-kit/compiler": "0.0.1-dev.20260706064107"
|
|
66
66
|
},
|
|
67
67
|
"publishConfig": {
|
|
68
68
|
"access": "public"
|
|
@@ -71,7 +71,7 @@
|
|
|
71
71
|
"build": "tsc",
|
|
72
72
|
"check-types": "tsc --noEmit",
|
|
73
73
|
"lint": "eslint . --max-warnings 0",
|
|
74
|
-
"test": "vitest run",
|
|
74
|
+
"test": "vitest run --reporter=default --reporter=json --outputFile.json=test-report.json --coverage.enabled --coverage.reporter=json-summary --coverage.reportsDirectory=coverage",
|
|
75
75
|
"test:dev": "vitest",
|
|
76
76
|
"test:coverage": "vitest run --coverage"
|
|
77
77
|
}
|