@theia/core
Version:
Theia is a cloud & desktop IDE framework implemented in TypeScript.
252 lines • 14.5 kB
JavaScript
;
// *****************************************************************************
// 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
// *****************************************************************************
Object.defineProperty(exports, "__esModule", { value: true });
const tslib_1 = require("tslib");
const chai_1 = require("chai");
const sinon = require("sinon");
const inversify_1 = require("inversify");
const common_1 = require("../common");
const promise_util_1 = require("../common/promise-util");
const mock_logger_1 = require("../common/test/mock-logger");
const node_stopwatch_1 = require("./performance/node-stopwatch");
const process_utils_1 = require("./process-utils");
const backend_application_1 = require("./backend-application");
const cli_1 = require("./cli");
/**
* Test subclass that exposes the protected `gracefulShutdown` for direct testing.
*/
let TestBackendApplication = class TestBackendApplication extends backend_application_1.BackendApplication {
invokeGracefulShutdown() {
return this.gracefulShutdown();
}
};
TestBackendApplication = tslib_1.__decorate([
(0, inversify_1.injectable)()
], TestBackendApplication);
// 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'];
describe('BackendApplication', () => {
let sandbox;
let exitStub;
let savedListeners;
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)];
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() {
const container = new inversify_1.Container();
container.bind(backend_application_1.RootContainer).toConstantValue(container);
container.bind(common_1.ILogger).to(mock_logger_1.MockLogger).inSingletonScope();
container.bind(common_1.Stopwatch).to(node_stopwatch_1.NodeStopwatch).inSingletonScope();
container.bind(process_utils_1.ProcessUtils).toSelf().inSingletonScope();
container.bind(backend_application_1.BackendApplicationCliContribution).toSelf().inSingletonScope();
container.bind(cli_1.CliContribution).toService(backend_application_1.BackendApplicationCliContribution);
(0, common_1.bindContributionProvider)(container, backend_application_1.BackendApplicationContribution);
container.bind(TestBackendApplication).toSelf().inSingletonScope();
container.bind(backend_application_1.BackendApplication).toService(TestBackendApplication);
return container;
}
describe('graceful shutdown', () => {
it('runs @preDestroy on root-scoped singletons before exiting with code 1', async () => {
let canaryDisposed = false;
let Canary = class Canary {
onPreDestroy() {
canaryDisposed = true;
}
};
tslib_1.__decorate([
(0, inversify_1.preDestroy)(),
tslib_1.__metadata("design:type", Function),
tslib_1.__metadata("design:paramtypes", []),
tslib_1.__metadata("design:returntype", void 0)
], Canary.prototype, "onPreDestroy", null);
Canary = tslib_1.__decorate([
(0, inversify_1.injectable)()
], Canary);
const container = createTestContainer();
const canaryModule = new inversify_1.ContainerModule(bind => {
bind(Canary).toSelf().inSingletonScope();
});
container.load(canaryModule);
container.get(Canary);
const app = container.get(TestBackendApplication);
await app.invokeGracefulShutdown();
(0, chai_1.expect)(canaryDisposed, '@preDestroy was not invoked on root-scoped singleton').to.be.true;
(0, chai_1.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();
(0, chai_1.expect)(unbindSpy.callCount, 'unbindAllAsync should be called only once').to.equal(1);
(0, chai_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();
(0, chai_1.expect)(exitStub.calledOnceWith(1), 'process.exit(1) was not called').to.be.true;
(0, chai_1.expect)(warnStub.calledOnce, 'a warning should be logged when cleanup rejects').to.be.true;
(0, chai_1.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(() => { }));
const warnStub = sandbox.stub(console, 'warn');
const app = container.get(TestBackendApplication);
const shutdownPromise = app.invokeGracefulShutdown();
await clock.tickAsync(5001);
await shutdownPromise;
(0, chai_1.expect)(exitStub.calledOnceWith(1), 'process.exit(1) was not called after timeout').to.be.true;
(0, chai_1.expect)(warnStub.calledOnce, 'a warning should be logged on timeout').to.be.true;
(0, chai_1.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 promise_util_1.Deferred();
container.bind(backend_application_1.BackendApplicationContribution).toConstantValue({
onStop: () => onStopDeferred.promise
});
const unbindSpy = sandbox.spy(container, 'unbindAllAsync');
const app = container.get(TestBackendApplication);
const shutdownPromise = app.invokeGracefulShutdown();
(0, chai_1.expect)(unbindSpy.called, 'unbindAllAsync should not run before onStop resolves').to.be.false;
onStopDeferred.resolve();
await shutdownPromise;
(0, chai_1.expect)(unbindSpy.calledOnce, 'unbindAllAsync should run once onStop completes').to.be.true;
(0, chai_1.expect)(exitStub.calledOnceWith(1)).to.be.true;
});
it('invokes onStop hooks while injected services are still resolvable', async () => {
let Helper = class Helper {
constructor() {
this.value = 'still-bound';
}
};
Helper = tslib_1.__decorate([
(0, inversify_1.injectable)()
], Helper);
const container = createTestContainer();
container.bind(Helper).toSelf().inSingletonScope();
let observed;
container.bind(backend_application_1.BackendApplicationContribution).toConstantValue({
onStop: () => {
observed = container.get(Helper).value;
}
});
const app = container.get(TestBackendApplication);
await app.invokeGracefulShutdown();
(0, chai_1.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(backend_application_1.BackendApplicationContribution).toConstantValue({
onStop: () => new Promise(() => { })
});
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;
(0, chai_1.expect)(warnStub.calledOnce, 'a warning should be logged on onStop timeout').to.be.true;
(0, chai_1.expect)(warnStub.firstCall.args[0]).to.match(/Stopping backend contributions/);
(0, chai_1.expect)(unbindSpy.calledOnce, 'unbind should still run after onStop times out').to.be.true;
(0, chai_1.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(backend_application_1.BackendApplicationContribution).toConstantValue({
onStop: async () => { throw new Error('boom'); }
});
container.bind(backend_application_1.BackendApplicationContribution).toConstantValue({
onStop: () => { secondRan = true; }
});
const errorStub = sandbox.stub(console, 'error');
const app = container.get(TestBackendApplication);
await app.invokeGracefulShutdown();
(0, chai_1.expect)(secondRan, 'second contribution should still be stopped after first rejects').to.be.true;
(0, chai_1.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 = {};
container.bind(backend_application_1.BackendApplicationContribution).toConstantValue({
onStop: () => appHolder.current.invokeGracefulShutdown()
});
const unbindSpy = sandbox.spy(container, 'unbindAllAsync');
appHolder.current = container.get(TestBackendApplication);
await appHolder.current.invokeGracefulShutdown();
(0, chai_1.expect)(unbindSpy.callCount, 'unbindAllAsync should be called only once').to.equal(1);
(0, chai_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(backend_application_1.BackendApplicationContribution).toConstantValue({ onStop: onStopSpy });
const terminateStub = sandbox.stub(process_utils_1.ProcessUtils.prototype, 'terminateProcessTree');
const app = container.get(TestBackendApplication);
const exitListener = process.listeners('exit')[0];
await app.invokeGracefulShutdown();
(0, chai_1.expect)(onStopSpy.callCount, 'contribution onStop should fire from gracefulShutdown').to.equal(1);
exitListener();
(0, chai_1.expect)(onStopSpy.callCount, 'contribution onStop should not be invoked a second time').to.equal(1);
(0, chai_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(backend_application_1.BackendApplicationContribution).toConstantValue({ onStop: onStopSpy });
const terminateStub = sandbox.stub(process_utils_1.ProcessUtils.prototype, 'terminateProcessTree');
container.get(TestBackendApplication);
const exitListener = process.listeners('exit')[0];
exitListener();
(0, chai_1.expect)(onStopSpy.calledOnce, 'sync exit path should still invoke contributions').to.be.true;
(0, chai_1.expect)(terminateStub.called, 'terminateProcessTree should be invoked').to.be.true;
});
});
});
//# sourceMappingURL=backend-application.spec.js.map