@dimina-kit/devtools 0.4.0-dev.20260718095333 → 0.4.0-dev.20260718143821

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.
@@ -0,0 +1,61 @@
1
+ import type { WorkbenchAppConfig } from '../../shared/types.js';
2
+ import type { RuntimeBackend } from '@dimina-kit/electron-deck';
3
+ declare const stubs: {
4
+ handlers: Map<string, (...args: unknown[]) => unknown>;
5
+ projectsJsonPath: string;
6
+ getProjectsJson(): string | null;
7
+ setProjectsJson(v: string | null): void;
8
+ projectsWithAppJson: Set<string>;
9
+ makeEmitter: () => {
10
+ listeners: Record<string, Set<(...args: unknown[]) => unknown>>;
11
+ on(event: string, fn: (...args: unknown[]) => unknown): /*elided*/ any;
12
+ once(event: string, fn: (...args: unknown[]) => unknown): /*elided*/ any;
13
+ off(event: string, fn: (...args: unknown[]) => unknown): /*elided*/ any;
14
+ removeListener(event: string, fn: (...args: unknown[]) => unknown): /*elided*/ any;
15
+ emit(event: string, ...args: unknown[]): void;
16
+ };
17
+ reset: () => void;
18
+ };
19
+ declare const devkitStubs: {
20
+ sessionClose: import("vitest").Mock<() => Promise<void>>;
21
+ };
22
+ declare const viewManagerStubs: {
23
+ createdManagers: Array<{
24
+ disposeAll: () => void;
25
+ }>;
26
+ };
27
+ export { stubs, devkitStubs, viewManagerStubs };
28
+ /**
29
+ * Shared per-test setup both `devtools-backend-before-quit.test.ts` and
30
+ * `devtools-backend-shutdown-race.test.ts` need identically: reset modules
31
+ * (fresh `let`-scoped module-level state in `devtools-backend.ts` each
32
+ * test), reset the mock harness above, and dynamically re-import `electron`
33
+ * + `createDevtoolsBackend` — dynamic, not a static top-level import,
34
+ * because it must happen AFTER `vi.resetModules()` picks up a fresh module
35
+ * instance each test. Registers its own `beforeEach`; call once per
36
+ * describing test file. Returns a mutable state object (not individual
37
+ * values) because `beforeEach` reassigns its fields once per test, after
38
+ * this function itself has already returned.
39
+ */
40
+ export interface BackendTestState {
41
+ createDevtoolsBackend: typeof import('./devtools-backend.js').createDevtoolsBackend;
42
+ electron: typeof import('electron');
43
+ }
44
+ export declare function registerBackendTestLifecycle(): BackendTestState;
45
+ /**
46
+ * Shared setup for the two tests (one in `devtools-backend-before-quit.test.ts`,
47
+ * one in `devtools-backend-shutdown-race.test.ts`) that need to observe
48
+ * `assemble()` PAUSED mid-flight, inside a still-pending `config.onSetup`:
49
+ * builds the backend with a gated `onSetup`, runs `beforeReady`/`assemble`,
50
+ * and waits until the assembled `ViewManager` is actually reachable (proof
51
+ * `createDevtoolsRuntime` has progressed past instance construction) before
52
+ * handing control back — the caller then fires whatever quit-path event it's
53
+ * testing while `onSetup` is still gated, and is responsible for calling
54
+ * `releaseOnSetup()` + awaiting `assemblePromise` itself.
55
+ */
56
+ export declare function startAssemblingWithGatedOnSetup(createDevtoolsBackend: BackendTestState['createDevtoolsBackend'], extraConfig?: Omit<WorkbenchAppConfig, 'onSetup'>): Promise<{
57
+ backend: RuntimeBackend;
58
+ assemblePromise: Promise<void>;
59
+ releaseOnSetup: () => void;
60
+ }>;
61
+ //# sourceMappingURL=devtools-backend-before-quit.harness.d.ts.map
@@ -0,0 +1,356 @@
1
+ /**
2
+ * Shared vitest mock harness for `devtools-backend-before-quit.test.ts` and
3
+ * `devtools-backend-shutdown-race.test.ts` — both drive the REAL
4
+ * `createDevtoolsBackend`/`createDevtoolsRuntime` against this same
5
+ * electron/fs/`@dimina-kit/devkit`/view-manager mock set (lifted from
6
+ * `quit-flag-onclose.test.ts`), split out to keep each test file under the
7
+ * repo's 500-line file-length ratchet without duplicating ~350 lines of
8
+ * mock setup between them.
9
+ *
10
+ * `vi.mock(...)` calls here run once per importing test file's own isolated
11
+ * module graph (vitest scopes mocking per test file), so importing this
12
+ * module has the same effect as if each call were written directly in the
13
+ * importing file — both test files' `beforeEach` still dynamically
14
+ * `await import('./devtools-backend.js')` AFTER `vi.resetModules()`, well
15
+ * after this module's mock registrations have already run.
16
+ */
17
+ import { beforeEach, expect, vi } from 'vitest';
18
+ // ── Hoisted stub state (lifted from quit-flag-onclose.test.ts) ────────────
19
+ // `export const x = vi.hoisted(...)` is rejected by vitest's hoisting
20
+ // transform ("Cannot export hoisted variable") — declare then export below.
21
+ const stubs = vi.hoisted(() => {
22
+ const handlers = new Map();
23
+ const projectsJsonPath = '/tmp/dimina-test-userdata/dimina-projects.json';
24
+ let projectsJsonContent = null;
25
+ const projectsWithAppJson = new Set();
26
+ function makeEmitter() {
27
+ const listeners = {};
28
+ return {
29
+ listeners,
30
+ on(event, fn) {
31
+ ;
32
+ (listeners[event] ??= new Set()).add(fn);
33
+ return this;
34
+ },
35
+ once(event, fn) {
36
+ const wrap = (...args) => {
37
+ listeners[event]?.delete(wrap);
38
+ return fn(...args);
39
+ };
40
+ (listeners[event] ??= new Set()).add(wrap);
41
+ return this;
42
+ },
43
+ off(event, fn) {
44
+ listeners[event]?.delete(fn);
45
+ return this;
46
+ },
47
+ removeListener(event, fn) {
48
+ listeners[event]?.delete(fn);
49
+ return this;
50
+ },
51
+ emit(event, ...args) {
52
+ for (const fn of [...(listeners[event] ?? [])])
53
+ fn(...args);
54
+ },
55
+ };
56
+ }
57
+ function reset() {
58
+ handlers.clear();
59
+ projectsJsonContent = null;
60
+ projectsWithAppJson.clear();
61
+ }
62
+ return {
63
+ handlers,
64
+ projectsJsonPath,
65
+ getProjectsJson() {
66
+ return projectsJsonContent;
67
+ },
68
+ setProjectsJson(v) {
69
+ projectsJsonContent = v;
70
+ },
71
+ projectsWithAppJson,
72
+ makeEmitter,
73
+ reset,
74
+ };
75
+ });
76
+ // ── electron stub ────────────────────────────────────────────────────────
77
+ // `app` is a live emitter so `registerAppLifecycle` can attach a
78
+ // `before-quit` listener that the test fires directly.
79
+ vi.mock('electron', () => {
80
+ const ipcEmitter = stubs.makeEmitter();
81
+ const ipcMain = {
82
+ ...ipcEmitter,
83
+ handle: vi.fn((channel, fn) => {
84
+ stubs.handlers.set(channel, fn);
85
+ }),
86
+ removeHandler: vi.fn((channel) => {
87
+ stubs.handlers.delete(channel);
88
+ }),
89
+ on: vi.fn((event, fn) => ipcEmitter.on(event, fn)),
90
+ removeListener: vi.fn((event, fn) => ipcEmitter.removeListener(event, fn)),
91
+ };
92
+ const appEmitter = stubs.makeEmitter();
93
+ const app = {
94
+ ...appEmitter,
95
+ isPackaged: true,
96
+ whenReady: vi.fn(() => Promise.resolve()),
97
+ getPath: vi.fn(() => '/tmp/dimina-test-userdata'),
98
+ quit: vi.fn(),
99
+ setName: vi.fn(),
100
+ commandLine: {
101
+ getSwitchValue: vi.fn(() => ''),
102
+ appendSwitch: vi.fn(),
103
+ },
104
+ };
105
+ // `WebContents` and `BrowserWindow` below both wrap a `stubs.makeEmitter()`
106
+ // and need the SAME on/once/off/removeListener/emit delegation (the
107
+ // emitter's own methods use `this` internally, so a plain property copy
108
+ // would call them with the wrong receiver — each needs `.bind(em)`). Shared
109
+ // base class instead of re-declaring the same 5 bound fields in both.
110
+ class EmittingStub {
111
+ em = stubs.makeEmitter();
112
+ on = this.em.on.bind(this.em);
113
+ once = this.em.once.bind(this.em);
114
+ off = this.em.off.bind(this.em);
115
+ removeListener = this.em.removeListener.bind(this.em);
116
+ emit = this.em.emit.bind(this.em);
117
+ }
118
+ class WebContents extends EmittingStub {
119
+ destroyed = false;
120
+ id = Math.floor(Math.random() * 1e6);
121
+ send = vi.fn();
122
+ isDestroyed = () => this.destroyed;
123
+ openDevTools = vi.fn();
124
+ closeDevTools = vi.fn();
125
+ setDevToolsWebContents = vi.fn();
126
+ setWindowOpenHandler = vi.fn();
127
+ loadFile = vi.fn(() => Promise.resolve());
128
+ loadURL = vi.fn(() => Promise.resolve());
129
+ executeJavaScript = vi.fn(() => Promise.resolve(undefined));
130
+ reload = vi.fn();
131
+ getType = () => 'window';
132
+ getURL = () => '';
133
+ debugger = {
134
+ attach: vi.fn(),
135
+ detach: vi.fn(),
136
+ isAttached: () => false,
137
+ on: vi.fn(),
138
+ removeListener: vi.fn(),
139
+ sendCommand: vi.fn(() => Promise.resolve({ entries: [] })),
140
+ };
141
+ close = vi.fn(() => {
142
+ this.destroyed = true;
143
+ });
144
+ }
145
+ class WebContentsView {
146
+ webContents = new WebContents();
147
+ setBounds = vi.fn();
148
+ setBackgroundColor = vi.fn();
149
+ }
150
+ class View {
151
+ children = [];
152
+ addChildView(child) {
153
+ this.children.push(child);
154
+ }
155
+ removeChildView(child) {
156
+ const i = this.children.indexOf(child);
157
+ if (i >= 0)
158
+ this.children.splice(i, 1);
159
+ }
160
+ }
161
+ class BrowserWindow extends EmittingStub {
162
+ destroyed = false;
163
+ webContents = new WebContents();
164
+ contentView = new WebContentsView();
165
+ isDestroyed = () => this.destroyed;
166
+ getContentSize = () => [1280, 980];
167
+ setIcon = vi.fn();
168
+ setTitle = vi.fn();
169
+ show = vi.fn();
170
+ showInactive = vi.fn();
171
+ focus = vi.fn();
172
+ close = vi.fn();
173
+ destroy = vi.fn(() => {
174
+ this.destroyed = true;
175
+ });
176
+ loadFile = vi.fn(() => Promise.resolve());
177
+ loadURL = vi.fn(() => Promise.resolve());
178
+ static getAllWindows = vi.fn(() => []);
179
+ }
180
+ const sessionStub = {
181
+ fromPartition: vi.fn(() => ({
182
+ webRequest: {
183
+ onBeforeSendHeaders: vi.fn(),
184
+ onHeadersReceived: vi.fn(),
185
+ },
186
+ registerPreloadScript: vi.fn(),
187
+ protocol: { handle: vi.fn(), unhandle: vi.fn() },
188
+ })),
189
+ defaultSession: {
190
+ protocol: { handle: vi.fn(), unhandle: vi.fn() },
191
+ },
192
+ };
193
+ const dialog = {
194
+ showOpenDialog: vi.fn(() => Promise.resolve({ canceled: true, filePaths: [] })),
195
+ showMessageBox: vi.fn(() => Promise.resolve({ response: 0 })),
196
+ };
197
+ const Menu = {
198
+ buildFromTemplate: vi.fn((tpl) => ({ template: tpl })),
199
+ setApplicationMenu: vi.fn(),
200
+ };
201
+ const shell = {
202
+ openExternal: vi.fn(() => Promise.resolve()),
203
+ openPath: vi.fn(() => Promise.resolve('')),
204
+ };
205
+ const nativeImage = {
206
+ createFromPath: vi.fn(() => ({ isEmpty: () => true })),
207
+ };
208
+ const nativeTheme = { ...stubs.makeEmitter(), themeSource: 'system' };
209
+ const globalShortcut = {
210
+ register: vi.fn(() => false),
211
+ unregister: vi.fn(),
212
+ unregisterAll: vi.fn(),
213
+ };
214
+ const webContentsStatic = {
215
+ fromId: vi.fn(() => null),
216
+ getAllWebContents: vi.fn(() => []),
217
+ };
218
+ const Tray = vi.fn();
219
+ return {
220
+ app,
221
+ ipcMain,
222
+ BrowserWindow,
223
+ WebContentsView,
224
+ BrowserView: WebContentsView,
225
+ View,
226
+ webContents: webContentsStatic,
227
+ session: sessionStub,
228
+ protocol: { registerSchemesAsPrivileged: vi.fn(), handle: vi.fn(), unhandle: vi.fn() },
229
+ dialog,
230
+ Menu,
231
+ shell,
232
+ nativeImage,
233
+ nativeTheme,
234
+ globalShortcut,
235
+ Tray,
236
+ default: {},
237
+ };
238
+ });
239
+ vi.mock('fs', async () => {
240
+ const real = await vi.importActual('fs');
241
+ function existsSync(p) {
242
+ const s = String(p);
243
+ if (s === stubs.projectsJsonPath)
244
+ return stubs.getProjectsJson() !== null;
245
+ if (s.endsWith('/app.json') || s.endsWith('\\app.json')) {
246
+ const dir = s.replace(/[\\/]app\.json$/, '');
247
+ return stubs.projectsWithAppJson.has(dir);
248
+ }
249
+ return true;
250
+ }
251
+ function readFileSync(p, opts) {
252
+ const s = String(p);
253
+ if (s === stubs.projectsJsonPath) {
254
+ const content = stubs.getProjectsJson();
255
+ if (content === null)
256
+ throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' });
257
+ return content;
258
+ }
259
+ return real.readFileSync(p, opts);
260
+ }
261
+ function writeFileSync(p, data) {
262
+ const s = String(p);
263
+ if (s === stubs.projectsJsonPath) {
264
+ stubs.setProjectsJson(typeof data === 'string' ? data : Buffer.from(data).toString('utf8'));
265
+ }
266
+ }
267
+ const mocked = {
268
+ ...real,
269
+ existsSync,
270
+ readFileSync,
271
+ writeFileSync,
272
+ mkdirSync: vi.fn(),
273
+ statSync: vi.fn(() => ({ isDirectory: () => true, isFile: () => false, size: 0, mtimeMs: 0 })),
274
+ watch: vi.fn(),
275
+ realpathSync: vi.fn((p) => p),
276
+ };
277
+ return { ...mocked, default: mocked };
278
+ });
279
+ const devkitStubs = vi.hoisted(() => ({
280
+ sessionClose: vi.fn(() => Promise.resolve()),
281
+ }));
282
+ vi.mock('@dimina-kit/devkit', () => ({
283
+ openProject: vi.fn(() => Promise.resolve({
284
+ port: 12345,
285
+ appInfo: { appId: 'fakeApp' },
286
+ close: devkitStubs.sessionClose,
287
+ })),
288
+ }));
289
+ // ── view-manager spy shim ───────────────────────────────────────────────
290
+ // `createDevtoolsBackend` keeps its assembled `instance` in a private
291
+ // closure — the returned `RuntimeBackend` exposes no getter for
292
+ // `instance.context.views`. Wrapping `createViewManager` here (real
293
+ // implementation, just observed) gives the test a handle on the exact
294
+ // `ViewManager` the backend's `assemble` wires up, equivalent to
295
+ // `vi.spyOn(instance.context.views, 'disposeAll')` without needing the
296
+ // backend to leak its internals.
297
+ const viewManagerStubs = vi.hoisted(() => ({
298
+ createdManagers: [],
299
+ }));
300
+ vi.mock('../services/views/view-manager.js', async (importOriginal) => {
301
+ const actual = await importOriginal();
302
+ return {
303
+ ...actual,
304
+ createViewManager: (ctx) => {
305
+ const real = actual.createViewManager(ctx);
306
+ vi.spyOn(real, 'disposeAll');
307
+ viewManagerStubs.createdManagers.push(real);
308
+ return real;
309
+ },
310
+ };
311
+ });
312
+ export { stubs, devkitStubs, viewManagerStubs };
313
+ export function registerBackendTestLifecycle() {
314
+ const state = {};
315
+ beforeEach(async () => {
316
+ vi.resetModules();
317
+ stubs.reset();
318
+ devkitStubs.sessionClose.mockClear();
319
+ viewManagerStubs.createdManagers.length = 0;
320
+ state.electron = await import('electron');
321
+ ({ createDevtoolsBackend: state.createDevtoolsBackend } = await import('./devtools-backend.js'));
322
+ });
323
+ return state;
324
+ }
325
+ /**
326
+ * Shared setup for the two tests (one in `devtools-backend-before-quit.test.ts`,
327
+ * one in `devtools-backend-shutdown-race.test.ts`) that need to observe
328
+ * `assemble()` PAUSED mid-flight, inside a still-pending `config.onSetup`:
329
+ * builds the backend with a gated `onSetup`, runs `beforeReady`/`assemble`,
330
+ * and waits until the assembled `ViewManager` is actually reachable (proof
331
+ * `createDevtoolsRuntime` has progressed past instance construction) before
332
+ * handing control back — the caller then fires whatever quit-path event it's
333
+ * testing while `onSetup` is still gated, and is responsible for calling
334
+ * `releaseOnSetup()` + awaiting `assemblePromise` itself.
335
+ */
336
+ export async function startAssemblingWithGatedOnSetup(createDevtoolsBackend, extraConfig = {}) {
337
+ let releaseOnSetup = () => { };
338
+ const onSetupGate = new Promise((resolve) => {
339
+ releaseOnSetup = resolve;
340
+ });
341
+ const backend = createDevtoolsBackend({
342
+ ...extraConfig,
343
+ onSetup: async () => {
344
+ await onSetupGate;
345
+ },
346
+ });
347
+ backend.beforeReady?.({});
348
+ const assemblePromise = Promise.resolve(backend.assemble({}));
349
+ // Let `assemble` progress through `createDevtoolsRuntime` up to (and into)
350
+ // the pending `onSetup` gate — the instance must exist by then.
351
+ await vi.waitFor(() => {
352
+ expect(viewManagerStubs.createdManagers.length).toBeGreaterThan(0);
353
+ });
354
+ return { backend, assemblePromise, releaseOnSetup };
355
+ }
356
+ //# sourceMappingURL=devtools-backend-before-quit.harness.js.map
@@ -1,5 +1,5 @@
1
1
  import { runDevtoolsBootstrap, createDevtoolsRuntime } from '../app/app.js';
2
- import { registerAppLifecycle } from '../app/lifecycle.js';
2
+ import { isAppQuitting, registerAppLifecycle } from '../app/lifecycle.js';
3
3
  /**
4
4
  * Adapts the devtools runtime into a {@link RuntimeBackend} the framework
5
5
  * (`@dimina-kit/electron-deck`) orchestrates. The framework owns the process
@@ -21,6 +21,15 @@ import { registerAppLifecycle } from '../app/lifecycle.js';
21
21
  export function createDevtoolsBackend(config = {}) {
22
22
  // Hoisted so `onShutdown` can reach the assembled context (assigned by `assemble`).
23
23
  let instance = null;
24
+ // `assemble`'s own in-flight promise: `instance` is now published EARLY (see
25
+ // below), before `createDevtoolsRuntime`'s async body — including the host's
26
+ // `config.onSetup(instance)` and everything after it — has finished running.
27
+ // `onShutdown` must wait for THIS to settle before disposing: disposing
28
+ // `instance.context.registry` while `createDevtoolsRuntime` is still adding
29
+ // entries to that same registry (e.g. the `updateChecker`/`setupMcp` wiring
30
+ // that runs after `onSetup`) hits `DisposableRegistry.add()`'s "cannot add
31
+ // to disposed registry" throw instead of a clean teardown.
32
+ let assembling = null;
24
33
  return {
25
34
  // The backend builds the devtools main window itself (framework skips its own).
26
35
  // Its only construction-time needs are the host preload + `sandbox:false`
@@ -42,15 +51,51 @@ export function createDevtoolsBackend(config = {}) {
42
51
  // Setup (post-whenReady): app lifecycle (window-all-closed → quit) + the
43
52
  // full devtools assembly.
44
53
  assemble: async () => {
45
- registerAppLifecycle();
46
- instance = await createDevtoolsRuntime(config);
54
+ // `onBeforeQuit` runs synchronously at Electron's `before-quit` — main
55
+ // loop still fully healthy, well before `will-quit`'s unawaited
56
+ // `shutdown()` and any window/WebContentsView destruction. Disposing
57
+ // the host-scoped views (host-toolbar's WebContentsView + its
58
+ // MessagePortMain) here, instead of leaving them to `onShutdown`'s
59
+ // best-effort async teardown, keeps them from surviving into
60
+ // Chromium's native shutdown sequence, where a late `'destroyed'`
61
+ // handler closing an already-torn-down MessagePort segfaults natively.
62
+ // Safe to run again from `onShutdown`'s `instance.dispose()` afterwards
63
+ // — `views.disposeAll()` and its constituents are dispose-idempotent.
64
+ registerAppLifecycle(() => instance?.context.views.disposeAll());
65
+ // `onInstanceCreated` (not the return value) is what assigns `instance`:
66
+ // `createDevtoolsRuntime` awaits the host's `config.onSetup(instance)`
67
+ // — which may run arbitrarily long and can itself load the host toolbar
68
+ // (a live MessagePort) — before returning. Waiting for the return value
69
+ // would leave `instance` null, and the before-quit hook above a no-op,
70
+ // for that entire window. Publishing from the callback closes it.
71
+ assembling = createDevtoolsRuntime(config, (created) => {
72
+ instance = created;
73
+ // Quit may have already started (before-quit already fired) before
74
+ // this instance existed, so the hook above ran with nothing to
75
+ // dispose. Self-heal: run the same teardown now that there is.
76
+ if (isAppQuitting())
77
+ instance.context.views.disposeAll();
78
+ }).then(() => { });
79
+ await assembling;
47
80
  },
48
81
  // Dispose the devtools context during the framework's deterministic shutdown
49
82
  // (app.on('will-quit') → shutdown() → runShutdownCleanup(), awaited once).
50
83
  // Without this, the compile session (a child process, torn down by
51
84
  // closeProject) and the IPC registry would leak on exit. The framework awaits
52
85
  // this hook, so teardown is no longer a best-effort before-quit reach-around.
53
- onShutdown: () => instance?.dispose(),
86
+ //
87
+ // Waits for `assembling` first: `instance` can be non-null (published
88
+ // early, above) while `createDevtoolsRuntime`'s own async body — the
89
+ // host's `onSetup` and everything scheduled after it — is still running
90
+ // and still adding entries to `instance.context.registry`. Disposing
91
+ // that registry out from under an in-flight assembly throws instead of
92
+ // tearing down cleanly (`DisposableRegistry.add` rejects post-dispose).
93
+ // `.catch()` here: assembly failing is `assemble`'s problem to surface,
94
+ // not a reason to skip disposing whatever DID get constructed.
95
+ onShutdown: async () => {
96
+ await assembling?.catch(() => { });
97
+ await instance?.dispose();
98
+ },
54
99
  };
55
100
  }
56
101
  //# sourceMappingURL=devtools-backend.js.map
@@ -39,7 +39,6 @@ export function startMcpServer(resolvedCdpPort, mcpPort) {
39
39
  // Connect to both targets (non-blocking)
40
40
  connectTarget('simulator').catch(() => { });
41
41
  connectTarget('workbench').catch(() => { });
42
- const server = buildServer();
43
42
  const transports = new Map();
44
43
  const httpServer = createServer(async (req, res) => {
45
44
  const url = new URL(req.url ?? '/', `http://localhost`);
@@ -47,7 +46,30 @@ export function startMcpServer(resolvedCdpPort, mcpPort) {
47
46
  const transport = new SSEServerTransport('/message', res);
48
47
  transports.set(transport.sessionId, transport);
49
48
  res.on('close', () => transports.delete(transport.sessionId));
50
- await server.connect(transport);
49
+ try {
50
+ // A fresh McpServer PER CONNECTION: the SDK's Protocol is strictly
51
+ // 1:1 with its transport (`connect()` throws "Already connected to a
52
+ // transport" if `this._transport` is already set), so reusing one
53
+ // global server across concurrent SSE clients breaks every
54
+ // connection after the first. Registration only builds tool-call
55
+ // closures over already-shared module-level state (target-manager's
56
+ // CDP connections/listeners) — it has no per-call side effects, so
57
+ // building N servers is cheap and duplicates nothing.
58
+ await buildServer().connect(transport);
59
+ }
60
+ catch (err) {
61
+ transports.delete(transport.sessionId);
62
+ // `connect()` calls `transport.start()` (writes the SSE 200 + headers)
63
+ // BEFORE it can throw, so a post-start failure here has already sent
64
+ // headers — writeHead would itself throw. End the response instead so
65
+ // the client's stream closes cleanly rather than hanging forever with
66
+ // an open connection nothing will ever write to again.
67
+ if (!res.headersSent)
68
+ res.writeHead(500).end('MCP connect failed');
69
+ else
70
+ res.end();
71
+ console.error('[MCP] SSE connect failed:', err);
72
+ }
51
73
  }
52
74
  else if (req.method === 'POST' && url.pathname === '/message') {
53
75
  const sessionId = url.searchParams.get('sessionId') ?? '';
@@ -63,9 +63,16 @@ async function materializeTemplate(target, template, name) {
63
63
  if (!fs.existsSync(template.source.path)) {
64
64
  throw new Error(`Template source missing on disk: ${template.source.path}`);
65
65
  }
66
- // `recursive: true` makes cpSync mirror the entire tree (files,
67
- // subdirs, symlinks). `force: true` mirrors over an empty target.
68
- fs.cpSync(template.source.path, target, {
66
+ // `recursive: true` makes cp mirror the entire tree (files, subdirs,
67
+ // symlinks). `force: true` mirrors over an empty target. `fs.promises.cp`
68
+ // (not the sync `fs.cpSync`) deliberately: cpSync's no-filter directory
69
+ // walk takes Node's native `cpSyncCopyDir` fast path, whose internal
70
+ // filesystem calls can `abort()` the whole process on certain errors
71
+ // (long/restricted/non-ASCII paths — nodejs/node#63970) instead of
72
+ // throwing a catchable JS exception. The async `cp` walks in pure JS and
73
+ // turns the same failures into a normal rejection this function already
74
+ // propagates to its IPC-boundary try/catch.
75
+ await fs.promises.cp(template.source.path, target, {
69
76
  recursive: true,
70
77
  force: true,
71
78
  });
@@ -207,6 +207,12 @@ function httpGetText(url, init) {
207
207
  function downloadFile(url, dest, token, onProgress) {
208
208
  return new Promise((resolve, reject) => {
209
209
  const headers = ghHeaders(token);
210
+ // Awaited before every reject so callers (and tests) that observe the
211
+ // rejection never race a partial file still being unlinked — fs.unlink's
212
+ // callback form settles asynchronously, after the promise had already
213
+ // rejected, which left a truncated/aborted download file on disk for a
214
+ // window after the caller believed cleanup was done.
215
+ const cleanup = () => fs.promises.unlink(dest).catch(() => { });
210
216
  // GitHub asset downloads redirect to signed S3 URLs; keep following them.
211
217
  const attempt = (target, redirectsLeft) => {
212
218
  const req = https.get(target, { headers }, (res) => {
@@ -236,11 +242,24 @@ function downloadFile(url, dest, token, onProgress) {
236
242
  onProgress((received / total) * 100);
237
243
  }
238
244
  });
245
+ // `.pipe()` does not forward source stream errors to the destination —
246
+ // an aborted/reset connection here would otherwise surface as an
247
+ // unhandled 'error' event on `res` (crashes the process) instead of
248
+ // rejecting this promise.
249
+ res.on('error', async (err) => { out.destroy(); await cleanup(); reject(err); });
239
250
  res.pipe(out);
240
- out.on('finish', () => { out.close(); resolve(); });
241
- out.on('error', (err) => { fs.unlink(dest, () => { }); reject(err); });
251
+ out.on('finish', async () => {
252
+ out.close();
253
+ if (total > 0 && received !== total) {
254
+ await cleanup();
255
+ reject(new Error(`Downloaded ${received} bytes, expected ${total} — update download was truncated`));
256
+ return;
257
+ }
258
+ resolve();
259
+ });
260
+ out.on('error', async (err) => { await cleanup(); reject(err); });
242
261
  });
243
- req.on('error', (err) => { fs.unlink(dest, () => { }); reject(err); });
262
+ req.on('error', async (err) => { await cleanup(); reject(err); });
244
263
  };
245
264
  attempt(url, 5);
246
265
  });
@@ -48,7 +48,10 @@ export declare class UpdateManager {
48
48
  filePath?: string;
49
49
  error?: string;
50
50
  }>;
51
- install(): void;
51
+ install(): Promise<{
52
+ success: boolean;
53
+ error?: string;
54
+ }>;
52
55
  /**
53
56
  * Tear down timers + IPC handlers. Returns a Promise so callers that need
54
57
  * a settled cleanup (e.g. workbench registry shutdown, the unit-test
@@ -39,8 +39,21 @@ export class UpdateManager {
39
39
  const currentVersion = this.getCurrentVersion();
40
40
  const info = await this.checker.checkForUpdates(currentVersion);
41
41
  if (info) {
42
+ // The periodic timer re-runs check() forever (see startPeriodicCheck),
43
+ // including after the user already downloaded this same update but
44
+ // before they clicked Install. Only invalidate downloadedPath when the
45
+ // update identity actually changed — unconditionally nulling it here
46
+ // would silently no-op a later install() (it's a bare `if (this.downloadedPath)`)
47
+ // for anyone who takes longer than one check interval to act. Compare
48
+ // downloadUrl too, not just version: a re-uploaded/replaced asset can
49
+ // keep the same version string under a different URL, and reusing the
50
+ // stale downloadedPath would then install bytes that were never
51
+ // actually verified against this checkForUpdates() result.
52
+ const isSameUpdate = this.latestUpdate?.version === info.version &&
53
+ this.latestUpdate?.downloadUrl === info.downloadUrl;
42
54
  this.latestUpdate = info;
43
- this.downloadedPath = null;
55
+ if (!isSameUpdate)
56
+ this.downloadedPath = null;
44
57
  return { hasUpdate: true, info };
45
58
  }
46
59
  return { hasUpdate: false };
@@ -66,11 +79,23 @@ export class UpdateManager {
66
79
  return { success: false, error: String(err) };
67
80
  }
68
81
  }
69
- install() {
70
- if (this.downloadedPath) {
71
- shell.openPath(this.downloadedPath);
72
- app.quit();
82
+ async install() {
83
+ if (!this.downloadedPath) {
84
+ return { success: false, error: 'No update downloaded' };
73
85
  }
86
+ // shell.openPath() resolves (never rejects) with an empty string on
87
+ // success or an error message on failure — it was previously fired
88
+ // fire-and-forget immediately followed by app.quit(), so a failure (e.g.
89
+ // no application associated with the installer) silently closed the app
90
+ // without ever launching the installer, and the renderer had no way to
91
+ // know anything went wrong.
92
+ const failure = await shell.openPath(this.downloadedPath);
93
+ if (failure) {
94
+ console.warn('[UpdateManager] install failed:', failure);
95
+ return { success: false, error: failure };
96
+ }
97
+ app.quit();
98
+ return { success: true };
74
99
  }
75
100
  /**
76
101
  * Tear down timers + IPC handlers. Returns a Promise so callers that need