@theia/core
Version:
Theia is a cloud & desktop IDE framework implemented in TypeScript.
311 lines (243 loc) • 13.5 kB
text/typescript
// *****************************************************************************
// Copyright (C) 2026 EclipseSource and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import { expect } from 'chai';
import * as sinon from 'sinon';
import { Container, ContainerModule, injectable, preDestroy } from 'inversify';
import { bindContributionProvider, ILogger, Stopwatch } from '../common';
import { Deferred } from '../common/promise-util';
import { MockLogger } from '../common/test/mock-logger';
import { NodeStopwatch } from './performance/node-stopwatch';
import { ProcessUtils } from './process-utils';
import {
BackendApplication,
BackendApplicationCliContribution,
BackendApplicationContribution,
RootContainer
} from './backend-application';
import { CliContribution } from './cli';
/**
* Test subclass that exposes the protected `gracefulShutdown` for direct testing.
*/
()
class TestBackendApplication extends BackendApplication {
public invokeGracefulShutdown(): Promise<void> {
return this.gracefulShutdown();
}
}
// All process events on which `BackendApplication` installs listeners in its
// constructor. We snapshot and restore them around each test to avoid leaking
// listeners across tests (and triggering `MaxListenersExceededWarning`).
const PROCESS_EVENTS = ['SIGINT', 'SIGTERM', 'SIGPIPE', 'exit', 'uncaughtException'] as const;
type ProcessEventName = typeof PROCESS_EVENTS[number];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type AnyListener = (...args: any[]) => void;
describe('BackendApplication', () => {
let sandbox: sinon.SinonSandbox;
let exitStub: sinon.SinonStub;
let savedListeners: Partial<Record<ProcessEventName, AnyListener[]>>;
beforeEach(() => {
sandbox = sinon.createSandbox();
// Snapshot any existing listeners so we can restore after each test
// (BackendApplication installs its own as part of construction).
savedListeners = {};
for (const evt of PROCESS_EVENTS) {
savedListeners[evt] = [...process.listeners(evt as NodeJS.Signals)] as AnyListener[];
process.removeAllListeners(evt);
}
exitStub = sandbox.stub(process, 'exit');
});
afterEach(() => {
for (const evt of PROCESS_EVENTS) {
process.removeAllListeners(evt);
for (const listener of savedListeners[evt] ?? []) {
process.on(evt, listener);
}
}
sandbox.restore();
});
function createTestContainer(): Container {
const container = new Container();
container.bind(RootContainer).toConstantValue(container);
container.bind(ILogger).to(MockLogger).inSingletonScope();
container.bind(Stopwatch).to(NodeStopwatch).inSingletonScope();
container.bind(ProcessUtils).toSelf().inSingletonScope();
container.bind(BackendApplicationCliContribution).toSelf().inSingletonScope();
container.bind(CliContribution).toService(BackendApplicationCliContribution);
bindContributionProvider(container, BackendApplicationContribution);
container.bind(TestBackendApplication).toSelf().inSingletonScope();
container.bind(BackendApplication).toService(TestBackendApplication);
return container;
}
describe('graceful shutdown', () => {
it('runs @preDestroy on root-scoped singletons before exiting with code 1', async () => {
let canaryDisposed = false;
()
class Canary {
()
protected onPreDestroy(): void {
canaryDisposed = true;
}
}
const container = createTestContainer();
const canaryModule = new ContainerModule(bind => {
bind(Canary).toSelf().inSingletonScope();
});
container.load(canaryModule);
container.get(Canary);
const app = container.get(TestBackendApplication);
await app.invokeGracefulShutdown();
expect(canaryDisposed, '@preDestroy was not invoked on root-scoped singleton').to.be.true;
expect(exitStub.calledOnceWith(1), 'process.exit(1) was not called exactly once').to.be.true;
});
it('is idempotent: a second invocation does not unbind the container twice', async () => {
const container = createTestContainer();
const unbindSpy = sandbox.spy(container, 'unbindAllAsync');
const app = container.get(TestBackendApplication);
await app.invokeGracefulShutdown();
await app.invokeGracefulShutdown();
expect(unbindSpy.callCount, 'unbindAllAsync should be called only once').to.equal(1);
expect(exitStub.callCount, 'process.exit should be called only once').to.equal(1);
});
it('still exits if container cleanup rejects', async () => {
const container = createTestContainer();
const cleanupError = new Error('cleanup boom');
sandbox.stub(container, 'unbindAllAsync').rejects(cleanupError);
const warnStub = sandbox.stub(console, 'warn');
const app = container.get(TestBackendApplication);
await app.invokeGracefulShutdown();
expect(exitStub.calledOnceWith(1), 'process.exit(1) was not called').to.be.true;
expect(warnStub.calledOnce, 'a warning should be logged when cleanup rejects').to.be.true;
expect(warnStub.firstCall.args[0]).to.match(/cleanup boom/);
});
it('exits even when container cleanup hangs past the timeout', async () => {
const clock = sandbox.useFakeTimers();
const container = createTestContainer();
sandbox.stub(container, 'unbindAllAsync').returns(new Promise<void>(() => { /* never */ }));
const warnStub = sandbox.stub(console, 'warn');
const app = container.get(TestBackendApplication);
const shutdownPromise = app.invokeGracefulShutdown();
await clock.tickAsync(5001);
await shutdownPromise;
expect(exitStub.calledOnceWith(1), 'process.exit(1) was not called after timeout').to.be.true;
expect(warnStub.calledOnce, 'a warning should be logged on timeout').to.be.true;
expect(warnStub.firstCall.args[0]).to.match(/timed out/);
});
it('awaits async onStop contributions before unbinding the container', async () => {
const container = createTestContainer();
const onStopDeferred = new Deferred<void>();
container.bind(BackendApplicationContribution).toConstantValue({
onStop: () => onStopDeferred.promise
});
const unbindSpy = sandbox.spy(container, 'unbindAllAsync');
const app = container.get(TestBackendApplication);
const shutdownPromise = app.invokeGracefulShutdown();
expect(unbindSpy.called, 'unbindAllAsync should not run before onStop resolves').to.be.false;
onStopDeferred.resolve();
await shutdownPromise;
expect(unbindSpy.calledOnce, 'unbindAllAsync should run once onStop completes').to.be.true;
expect(exitStub.calledOnceWith(1)).to.be.true;
});
it('invokes onStop hooks while injected services are still resolvable', async () => {
()
class Helper {
readonly value = 'still-bound';
}
const container = createTestContainer();
container.bind(Helper).toSelf().inSingletonScope();
let observed: string | undefined;
container.bind(BackendApplicationContribution).toConstantValue({
onStop: () => {
observed = container.get(Helper).value;
}
});
const app = container.get(TestBackendApplication);
await app.invokeGracefulShutdown();
expect(observed, 'onStop should observe injected services that are still bound').to.equal('still-bound');
});
it('proceeds with shutdown when onStop hooks exceed the timeout', async () => {
const clock = sandbox.useFakeTimers();
const container = createTestContainer();
container.bind(BackendApplicationContribution).toConstantValue({
onStop: () => new Promise<void>(() => { /* never */ })
});
const unbindSpy = sandbox.spy(container, 'unbindAllAsync');
const warnStub = sandbox.stub(console, 'warn');
const app = container.get(TestBackendApplication);
const shutdownPromise = app.invokeGracefulShutdown();
await clock.tickAsync(5001);
await shutdownPromise;
expect(warnStub.calledOnce, 'a warning should be logged on onStop timeout').to.be.true;
expect(warnStub.firstCall.args[0]).to.match(/Stopping backend contributions/);
expect(unbindSpy.calledOnce, 'unbind should still run after onStop times out').to.be.true;
expect(exitStub.calledOnceWith(1)).to.be.true;
});
it('runs all contributions even when one onStop rejects', async () => {
const container = createTestContainer();
let secondRan = false;
container.bind(BackendApplicationContribution).toConstantValue({
onStop: async () => { throw new Error('boom'); }
});
container.bind(BackendApplicationContribution).toConstantValue({
onStop: () => { secondRan = true; }
});
const errorStub = sandbox.stub(console, 'error');
const app = container.get(TestBackendApplication);
await app.invokeGracefulShutdown();
expect(secondRan, 'second contribution should still be stopped after first rejects').to.be.true;
expect(errorStub.calledWithMatch('Could not stop contribution')).to.be.true;
});
it('is idempotent when a contribution re-enters graceful shutdown', async () => {
const container = createTestContainer();
// Indirected through a holder so the contribution closure can refer to the
// application instance that is constructed after the binding is recorded.
const appHolder: { current?: TestBackendApplication } = {};
container.bind(BackendApplicationContribution).toConstantValue({
onStop: () => appHolder.current!.invokeGracefulShutdown()
});
const unbindSpy = sandbox.spy(container, 'unbindAllAsync');
appHolder.current = container.get(TestBackendApplication);
await appHolder.current.invokeGracefulShutdown();
expect(unbindSpy.callCount, 'unbindAllAsync should be called only once').to.equal(1);
expect(exitStub.callCount, 'process.exit should be called only once').to.equal(1);
});
});
describe('process exit handler', () => {
it('does not re-invoke contributions after graceful shutdown ran them', async () => {
const container = createTestContainer();
const onStopSpy = sandbox.spy();
container.bind(BackendApplicationContribution).toConstantValue({ onStop: onStopSpy });
const terminateStub = sandbox.stub(ProcessUtils.prototype, 'terminateProcessTree');
const app = container.get(TestBackendApplication);
const exitListener = process.listeners('exit')[0] as () => void;
await app.invokeGracefulShutdown();
expect(onStopSpy.callCount, 'contribution onStop should fire from gracefulShutdown').to.equal(1);
exitListener();
expect(onStopSpy.callCount, 'contribution onStop should not be invoked a second time').to.equal(1);
expect(terminateStub.called, 'terminateProcessTree should be invoked by the exit handler').to.be.true;
});
it('invokes contributions synchronously when graceful shutdown was bypassed', () => {
const container = createTestContainer();
const onStopSpy = sandbox.spy();
container.bind(BackendApplicationContribution).toConstantValue({ onStop: onStopSpy });
const terminateStub = sandbox.stub(ProcessUtils.prototype, 'terminateProcessTree');
container.get(TestBackendApplication);
const exitListener = process.listeners('exit')[0] as () => void;
exitListener();
expect(onStopSpy.calledOnce, 'sync exit path should still invoke contributions').to.be.true;
expect(terminateStub.called, 'terminateProcessTree should be invoked').to.be.true;
});
});
});