@theia/core 1.72.0-next.42 → 1.72.0-next.46

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.
@@ -21,10 +21,10 @@ import * as https from 'https';
21
21
  import * as express from 'express';
22
22
  import * as yargs from 'yargs';
23
23
  import * as fs from 'fs-extra';
24
- import { inject, named, injectable, postConstruct } from 'inversify';
24
+ import { inject, named, injectable, type interfaces, postConstruct } from 'inversify';
25
25
  import { ContributionProvider, LogLevel, MaybePromise, MeasurementContext, Stopwatch } from '../common';
26
26
  import { CliContribution } from './cli';
27
- import { Deferred } from '../common/promise-util';
27
+ import { Deferred, timeoutReject } from '../common/promise-util';
28
28
  import { environment } from '../common/index';
29
29
  import { AddressInfo } from 'net';
30
30
  import { ProcessUtils } from './process-utils';
@@ -35,12 +35,19 @@ import { ProcessUtils } from './process-utils';
35
35
  */
36
36
  export const BackendApplicationPath = process.env.THEIA_APP_PROJECT_PATH || process.cwd();
37
37
 
38
+ /**
39
+ * Private injection token for the backend's root Inversify {@link Container}.
40
+ */
41
+ export const RootContainer = Symbol('RootContainer');
42
+
38
43
  export type DnsResultOrder = 'ipv4first' | 'verbatim' | 'nodeDefault';
39
44
 
40
45
  const APP_PROJECT_PATH = 'app-project-path';
41
46
 
42
47
  const TIMER_WARNING_THRESHOLD = 50;
43
48
 
49
+ const SHUTDOWN_TIMEOUT_MS = 5000;
50
+
44
51
  const DEFAULT_PORT = environment.electron.is() ? 0 : 3000;
45
52
  const DEFAULT_HOST = 'localhost';
46
53
  const DEFAULT_SSL = false;
@@ -103,12 +110,23 @@ export interface BackendApplicationContribution {
103
110
  onStart?(server: http.Server | https.Server): MaybePromise<void>;
104
111
 
105
112
  /**
106
- * Called when the backend application shuts down. Contributions must perform only synchronous operations.
107
- * Any kind of additional asynchronous work queued in the event loop will be ignored and abandoned.
113
+ * Called when the backend application shuts down.
114
+ *
115
+ * When shutdown is initiated via `SIGINT`/`SIGTERM`, contributions are dispatched
116
+ * in parallel and any returned promise is awaited up to `SHUTDOWN_TIMEOUT_MS`
117
+ * milliseconds while injected services from the root container are still
118
+ * resolvable.
119
+ *
120
+ * On synchronous-exit fallback paths (uncaught exceptions, server bind failures,
121
+ * or normal process exit), the hook is invoked synchronously and any returned
122
+ * promise is discarded. Implementations should be resilient to either path.
123
+ *
124
+ * Contributions must be independent of one another during stop because they are
125
+ * dispatched in parallel.
108
126
  *
109
127
  * @param app the express application.
110
128
  */
111
- onStop?(app?: express.Application): void;
129
+ onStop?(app?: express.Application): MaybePromise<void>;
112
130
  }
113
131
 
114
132
  @injectable()
@@ -162,8 +180,14 @@ export class BackendApplication {
162
180
  @inject(Stopwatch)
163
181
  protected readonly stopwatch: Stopwatch;
164
182
 
183
+ @inject(RootContainer)
184
+ protected readonly rootContainer: interfaces.Container;
185
+
165
186
  private _configured: Promise<void>;
166
187
 
188
+ private stoppedContributions = false;
189
+ private shuttingDown = false;
190
+
167
191
  private settlementContext?: MeasurementContext<BackendApplicationContribution>;
168
192
 
169
193
  constructor(
@@ -179,18 +203,15 @@ export class BackendApplication {
179
203
  process.on('SIGPIPE', () => {
180
204
  console.error(new Error('Unexpected SIGPIPE'));
181
205
  });
182
- /**
183
- * Kill the current process tree on exit.
184
- */
185
- function signalHandler(signal: NodeJS.Signals): never {
186
- process.exit(1);
187
- }
206
+
188
207
  // Handles normal process termination.
189
208
  process.on('exit', () => this.onStop());
190
- // Handles `Ctrl+C`.
191
- process.on('SIGINT', signalHandler);
192
- // Handles `kill pid`.
193
- process.on('SIGTERM', signalHandler);
209
+
210
+ // Handles `Ctrl+C` and `kill pid`. Delegates to gracefulShutdown so that
211
+ // root-scoped singletons get their @preDestroy hooks invoked before exit.
212
+ const onSignal = () => { this.gracefulShutdown().catch(err => console.error(err)); };
213
+ process.on('SIGINT', onSignal);
214
+ process.on('SIGTERM', onSignal);
194
215
  }
195
216
 
196
217
  protected async initialize(): Promise<void> {
@@ -332,18 +353,82 @@ export class BackendApplication {
332
353
  : `${scheme}://${address}:${port}`;
333
354
  }
334
355
 
335
- protected onStop(): void {
356
+ /**
357
+ * Performs an asynchronous shutdown of the backend in two phases:
358
+ *
359
+ * 1. Contributions' {@link BackendApplicationContribution.onStop onStop} hooks are
360
+ * dispatched in parallel and awaited so that they can still resolve services
361
+ * from the root Inversify container while it is bound.
362
+ * 2. All services in the root container are unbound, running their `@preDestroy`
363
+ * hooks.
364
+ *
365
+ * Each phase has its own {@link SHUTDOWN_TIMEOUT_MS} budget to avoid hanging on a
366
+ * misbehaving hook. Late-resolving promises from a timed-out phase may still
367
+ * settle in the background and could log noisily or interact with a partly
368
+ * unbound container; this is accepted because the process is exiting.
369
+ *
370
+ * Idempotent: a second invocation is a no-op. Exits the process with code 1 so
371
+ * that the `process.on('exit')` handler runs for fallback cleanup such as
372
+ * {@link ProcessUtils.terminateProcessTree}; the exit handler does not re-invoke
373
+ * contribution `onStop()` hooks that this method already dispatched.
374
+ */
375
+ protected async gracefulShutdown(): Promise<void> {
376
+ if (this.shuttingDown) {
377
+ return;
378
+ }
379
+ this.shuttingDown = true;
380
+
381
+ try {
382
+ await Promise.race([
383
+ this.stopContributions(),
384
+ timeoutReject<void>(SHUTDOWN_TIMEOUT_MS, `Stopping backend contributions timed out after ${SHUTDOWN_TIMEOUT_MS}ms`)
385
+ ]);
386
+ } catch (err) {
387
+ const message = err instanceof Error ? err.message : String(err);
388
+ console.warn(`Backend contributions cleanup failed: ${message}`);
389
+ }
390
+
391
+ try {
392
+ await Promise.race([
393
+ this.rootContainer.unbindAllAsync(),
394
+ timeoutReject<void>(SHUTDOWN_TIMEOUT_MS, `Container unbind timed out after ${SHUTDOWN_TIMEOUT_MS}ms`)
395
+ ]);
396
+ } catch (err) {
397
+ const message = err instanceof Error ? err.message : String(err);
398
+ console.warn(`Backend root container cleanup failed: ${message}`);
399
+ }
400
+
401
+ process.exit(1);
402
+ }
403
+
404
+ protected async stopContributions(): Promise<void> {
405
+ if (this.stoppedContributions) {
406
+ return;
407
+ }
408
+ this.stoppedContributions = true;
336
409
  console.info('>>> Stopping backend contributions...');
337
- for (const contrib of this.contributionsProvider.getContributions()) {
410
+ // The `async` wrapper converts a synchronous throw inside a non-async
411
+ // contribution's `onStop` into a rejected promise so the per-contribution
412
+ // try/catch can handle it; otherwise `Promise.all` would abort.
413
+ await Promise.all(this.contributionsProvider.getContributions().map(async contrib => {
338
414
  if (contrib.onStop) {
339
415
  try {
340
- contrib.onStop(this.app);
416
+ await contrib.onStop(this.app);
341
417
  } catch (error) {
342
418
  console.error('Could not stop contribution', error);
343
419
  }
344
420
  }
345
- }
421
+ }));
346
422
  console.info('<<< All backend contributions have been stopped.');
423
+ }
424
+
425
+ protected onStop(): void {
426
+ // Deliberate fire-and-forget of an async `stopContributions`()` call.
427
+ // It invokes each contribution's `onStop` synchronously up to its
428
+ // first `await`, so any synchronous cleanup runs before
429
+ // `terminateProcessTree`. Any returned promises are abandoned because
430
+ // the `'exit'` event does not yield back to the event loop.
431
+ this.stopContributions();
347
432
  this.processUtils.terminateProcessTree(process.pid);
348
433
  }
349
434