@hydranium/client-theia 1.0.0-next.10
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/LICENSE +21 -0
- package/README.md +113 -0
- package/lib/browser/browser-capture.d.ts +21 -0
- package/lib/browser/browser-capture.d.ts.map +1 -0
- package/lib/browser/browser-capture.js +61 -0
- package/lib/browser/browser-capture.js.map +1 -0
- package/lib/browser/channel-logger.d.ts +87 -0
- package/lib/browser/channel-logger.d.ts.map +1 -0
- package/lib/browser/channel-logger.js +149 -0
- package/lib/browser/channel-logger.js.map +1 -0
- package/lib/browser/index.d.ts +13 -0
- package/lib/browser/index.d.ts.map +1 -0
- package/lib/browser/index.js +30 -0
- package/lib/browser/index.js.map +1 -0
- package/lib/browser/log-level-preference.d.ts +82 -0
- package/lib/browser/log-level-preference.d.ts.map +1 -0
- package/lib/browser/log-level-preference.js +146 -0
- package/lib/browser/log-level-preference.js.map +1 -0
- package/lib/browser/memory-diagnostics-contribution.d.ts +105 -0
- package/lib/browser/memory-diagnostics-contribution.d.ts.map +1 -0
- package/lib/browser/memory-diagnostics-contribution.js +229 -0
- package/lib/browser/memory-diagnostics-contribution.js.map +1 -0
- package/lib/index.d.ts +10 -0
- package/lib/index.d.ts.map +1 -0
- package/lib/index.js +11 -0
- package/lib/index.js.map +1 -0
- package/lib/node/abstract-socket-forwarding-connection-handler.d.ts +96 -0
- package/lib/node/abstract-socket-forwarding-connection-handler.d.ts.map +1 -0
- package/lib/node/abstract-socket-forwarding-connection-handler.js +224 -0
- package/lib/node/abstract-socket-forwarding-connection-handler.js.map +1 -0
- package/lib/node/index.d.ts +10 -0
- package/lib/node/index.d.ts.map +1 -0
- package/lib/node/index.js +26 -0
- package/lib/node/index.js.map +1 -0
- package/lib/testing/index.d.ts +11 -0
- package/lib/testing/index.d.ts.map +1 -0
- package/lib/testing/index.js +29 -0
- package/lib/testing/index.js.map +1 -0
- package/lib/testing/stub-inversify-context.d.ts +15 -0
- package/lib/testing/stub-inversify-context.d.ts.map +1 -0
- package/lib/testing/stub-inversify-context.js +32 -0
- package/lib/testing/stub-inversify-context.js.map +1 -0
- package/lib/testing/stub-output-channel.d.ts +25 -0
- package/lib/testing/stub-output-channel.d.ts.map +1 -0
- package/lib/testing/stub-output-channel.js +33 -0
- package/lib/testing/stub-output-channel.js.map +1 -0
- package/package.json +95 -0
- package/src/browser/browser-capture.ts +75 -0
- package/src/browser/channel-logger.ts +160 -0
- package/src/browser/index.ts +14 -0
- package/src/browser/log-level-preference.ts +127 -0
- package/src/browser/memory-diagnostics-contribution.ts +248 -0
- package/src/index.ts +19 -0
- package/src/node/abstract-socket-forwarding-connection-handler.ts +218 -0
- package/src/node/index.ts +10 -0
- package/src/testing/index.ts +14 -0
- package/src/testing/stub-inversify-context.ts +32 -0
- package/src/testing/stub-output-channel.ts +47 -0
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/********************************************************************************
|
|
2
|
+
* Copyright (c) 2026 CrossBreeze, EclipseSource and others.
|
|
3
|
+
*
|
|
4
|
+
* This program and the accompanying materials are made available under the
|
|
5
|
+
* terms of the MIT License which is available in the project root.
|
|
6
|
+
*
|
|
7
|
+
* SPDX-License-Identifier: MIT
|
|
8
|
+
********************************************************************************/
|
|
9
|
+
|
|
10
|
+
import { AbstractLogger, DefaultTracer, type LogLevel, type MemoryReader, SystemClock } from '@hydranium/protocol';
|
|
11
|
+
import { injectable, type interfaces, unmanaged, inject } from '@theia/core/shared/inversify';
|
|
12
|
+
import { type OutputChannel, OutputChannelManager } from '@theia/output/lib/browser/output-channel';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Configuration for a {@link ChannelLogger}, supplied once at bind time via
|
|
16
|
+
* {@link bindChannelLogger} (the framework's `(deps, options)` constructor
|
|
17
|
+
* shape — the only piece an adopter must provide is the channel name).
|
|
18
|
+
*
|
|
19
|
+
* Note there is no log-threshold option here: the threshold is not a per-logger
|
|
20
|
+
* concern. See {@link ChannelLogger} for why, and
|
|
21
|
+
* `LogLevelPreferenceContribution` for the owner.
|
|
22
|
+
*/
|
|
23
|
+
export interface ChannelLoggerOptions {
|
|
24
|
+
/** Name of the Theia Output channel lines are written to. */
|
|
25
|
+
readonly channelName: string;
|
|
26
|
+
/** Initial logger component prefix (see `Logger.for`). */
|
|
27
|
+
readonly component?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Inversify token for the {@link ChannelLoggerOptions} constant (merged with the
|
|
31
|
+
* interface name — the standard Theia "type + token under one identifier" idiom). */
|
|
32
|
+
export const ChannelLoggerOptions = Symbol('ChannelLoggerOptions');
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Writes client-side lines to a Theia Output channel, formatted to align with
|
|
36
|
+
* the server-side `LspLogger` so both sides of the conversation interleave
|
|
37
|
+
* cleanly under the same Output panel entry. Cross-head: the data, LSP, and
|
|
38
|
+
* GLSP Theia integrations can all reuse it — nothing here is GLSP-specific.
|
|
39
|
+
*
|
|
40
|
+
* Concrete and options-configured: adopters wire it with
|
|
41
|
+
* {@link bindChannelLogger}, supplying {@link ChannelLoggerOptions}, rather
|
|
42
|
+
* than subclassing to pin a channel name.
|
|
43
|
+
*
|
|
44
|
+
* It deliberately owns **no** threshold logic. The threshold is a process-global
|
|
45
|
+
* shared by every `AbstractLogger`, whereas this class's singleton scope is
|
|
46
|
+
* whichever container bound it (for a GLSP head, one per diagram) — so applying a
|
|
47
|
+
* global from here would subscribe a preference listener per container and never
|
|
48
|
+
* dispose one. `LogLevelPreferenceContribution` owns it instead, at application
|
|
49
|
+
* scope. This class only formats and appends.
|
|
50
|
+
*/
|
|
51
|
+
@injectable()
|
|
52
|
+
export class ChannelLogger extends AbstractLogger {
|
|
53
|
+
protected _channel?: OutputChannel;
|
|
54
|
+
|
|
55
|
+
constructor(
|
|
56
|
+
@inject(OutputChannelManager) protected readonly outputChannels: OutputChannelManager,
|
|
57
|
+
@inject(ChannelLoggerOptions) protected readonly options: ChannelLoggerOptions,
|
|
58
|
+
@unmanaged() component: string | undefined = options.component
|
|
59
|
+
) {
|
|
60
|
+
super(component);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
protected get channel(): OutputChannel {
|
|
64
|
+
return (this._channel ??= this.outputChannels.getChannel(this.options.channelName));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
protected emit(_level: LogLevel, label: string, message: string, args: readonly unknown[]): void {
|
|
68
|
+
const componentSegment = this.component ? ` [${this.component}]` : '';
|
|
69
|
+
const formattedArgs = args.length > 0 ? ' ' + args.map(stringifyArg).join(' ') : '';
|
|
70
|
+
// Pad to match the server's 5-char label width so timestamps align across sources.
|
|
71
|
+
this.channel.appendLine(`[${label.padEnd(5)} - ${this.timestamp()}]${componentSegment} ${message}${formattedArgs}`);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Allocates a free instance bypassing Inversify, so a derived logger inherits
|
|
76
|
+
* the same OutputChannelManager + options without going through the container
|
|
77
|
+
* singleton. Subclasses with additional constructor dependencies must
|
|
78
|
+
* override and pass the extra args along.
|
|
79
|
+
*/
|
|
80
|
+
protected derive(component: string): this {
|
|
81
|
+
const Ctor = this.constructor as new (
|
|
82
|
+
outputChannels: OutputChannelManager,
|
|
83
|
+
options: ChannelLoggerOptions,
|
|
84
|
+
component?: string
|
|
85
|
+
) => this;
|
|
86
|
+
return new Ctor(this.outputChannels, this.options, component);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Private token for the un-tagged singleton base that the per-class dynamic
|
|
91
|
+
* binding derives from. Kept module-internal so the only public token is the
|
|
92
|
+
* {@link ChannelLogger} class itself. */
|
|
93
|
+
const ChannelLoggerBase = Symbol('ChannelLoggerBase');
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Inversify token for the browser `Tracer` — a {@link DefaultTracer} over
|
|
97
|
+
* the {@link ChannelLogger} (emit sink) + a {@link SystemClock} + a browser
|
|
98
|
+
* heap reader. The browser head has no Langium `services.Tracer` slot, so this
|
|
99
|
+
* is how frontend services that time obtain a tracer: `@inject(ChannelTracer)`.
|
|
100
|
+
* Bound by {@link bindChannelLogger}.
|
|
101
|
+
*/
|
|
102
|
+
export const ChannelTracer = Symbol('ChannelTracer');
|
|
103
|
+
|
|
104
|
+
/** Reads the requesting class's name from an Inversify context, by walking the
|
|
105
|
+
* current request up to its parent binding. */
|
|
106
|
+
export function getRequestParentName(context: interfaces.Context): string | undefined {
|
|
107
|
+
const parent = context.currentRequest.parentRequest;
|
|
108
|
+
if (!parent || parent.bindings.length === 0) {
|
|
109
|
+
return undefined;
|
|
110
|
+
}
|
|
111
|
+
const binding = parent.bindings[0] as interfaces.Binding<{ name?: string }>;
|
|
112
|
+
return binding.implementationType?.name;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Bind the {@link ChannelLogger} for an adopter's Theia frontend:
|
|
117
|
+
*
|
|
118
|
+
* - `bind(ChannelLoggerOptions).toConstantValue(options)` — the config
|
|
119
|
+
* - the un-tagged singleton base (private token), constructed by Inversify
|
|
120
|
+
* - `bind(ChannelLogger).toDynamicValue(...)` — class-scoped per request, so
|
|
121
|
+
* every `@inject(ChannelLogger)` site gets a logger pre-tagged with the
|
|
122
|
+
* requesting class name
|
|
123
|
+
*
|
|
124
|
+
* Adopters that compose their own module (bypassing a head's module helper)
|
|
125
|
+
* call this directly; head module helpers (`createGlspClientTheiaModule`)
|
|
126
|
+
* forward to it.
|
|
127
|
+
*/
|
|
128
|
+
export function bindChannelLogger(bind: interfaces.Bind, options: ChannelLoggerOptions): void {
|
|
129
|
+
bind(ChannelLoggerOptions).toConstantValue(options);
|
|
130
|
+
bind(ChannelLoggerBase).to(ChannelLogger).inSingletonScope();
|
|
131
|
+
bind(ChannelLogger).toDynamicValue(ctx => {
|
|
132
|
+
const base = ctx.container.get<ChannelLogger>(ChannelLoggerBase);
|
|
133
|
+
const parentName = getRequestParentName(ctx);
|
|
134
|
+
return parentName ? base.for(parentName) : base;
|
|
135
|
+
});
|
|
136
|
+
bind(ChannelTracer)
|
|
137
|
+
.toDynamicValue(ctx => new DefaultTracer(ctx.container.get<ChannelLogger>(ChannelLoggerBase), new SystemClock(), readBrowserMemory))
|
|
138
|
+
.inSingletonScope();
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
interface ChromiumPerformance {
|
|
142
|
+
memory?: { usedJSHeapSize: number; totalJSHeapSize: number; jsHeapSizeLimit: number };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Browser heap reader for {@link ChannelTracer}, backed by Chromium's non-standard `performance.memory`. */
|
|
146
|
+
const readBrowserMemory: MemoryReader = () => {
|
|
147
|
+
const mem = (performance as unknown as ChromiumPerformance).memory;
|
|
148
|
+
return mem ? { usedBytes: mem.usedJSHeapSize, totalBytes: mem.totalJSHeapSize } : undefined;
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
function stringifyArg(arg: unknown): string {
|
|
152
|
+
if (typeof arg === 'string') {
|
|
153
|
+
return arg;
|
|
154
|
+
}
|
|
155
|
+
try {
|
|
156
|
+
return JSON.stringify(arg);
|
|
157
|
+
} catch {
|
|
158
|
+
return String(arg);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/********************************************************************************
|
|
2
|
+
* Copyright (c) 2026 CrossBreeze, EclipseSource and others.
|
|
3
|
+
*
|
|
4
|
+
* This program and the accompanying materials are made available under the
|
|
5
|
+
* terms of the MIT License which is available in the project root.
|
|
6
|
+
*
|
|
7
|
+
* SPDX-License-Identifier: MIT
|
|
8
|
+
********************************************************************************/
|
|
9
|
+
|
|
10
|
+
// Browser-side cross-head Theia client primitives shared by the protocol heads.
|
|
11
|
+
export * from './channel-logger';
|
|
12
|
+
export * from './log-level-preference';
|
|
13
|
+
export * from './browser-capture';
|
|
14
|
+
export * from './memory-diagnostics-contribution';
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/********************************************************************************
|
|
2
|
+
* Copyright (c) 2026 CrossBreeze, EclipseSource and others.
|
|
3
|
+
*
|
|
4
|
+
* This program and the accompanying materials are made available under the
|
|
5
|
+
* terms of the MIT License which is available in the project root.
|
|
6
|
+
*
|
|
7
|
+
* SPDX-License-Identifier: MIT
|
|
8
|
+
********************************************************************************/
|
|
9
|
+
|
|
10
|
+
import { DisposableCollection, Logger, parseLogLevel } from '@hydranium/protocol';
|
|
11
|
+
// Narrow import paths, not the `@theia/core/lib/browser` barrel: it touches DOM
|
|
12
|
+
// globals at module load, which breaks node-environment unit tests. This module
|
|
13
|
+
// only needs the contribution token (a bare Symbol) and two common services.
|
|
14
|
+
import { FrontendApplicationContribution } from '@theia/core/lib/browser/frontend-application-contribution';
|
|
15
|
+
import { ILogger } from '@theia/core/lib/common/logger';
|
|
16
|
+
import { PreferenceService } from '@theia/core/lib/common/preferences/preference-service';
|
|
17
|
+
import { inject, injectable, type interfaces } from '@theia/core/shared/inversify';
|
|
18
|
+
|
|
19
|
+
/** Inversify token for the preference id {@link LogLevelPreferenceContribution} watches. */
|
|
20
|
+
export const LogLevelPreference = Symbol('LogLevelPreference');
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Applies the framework log threshold from a Theia preference, once per
|
|
24
|
+
* application.
|
|
25
|
+
*
|
|
26
|
+
* **Why this is a `FrontendApplicationContribution` and not a logger concern.**
|
|
27
|
+
* The threshold is a process-global (`Logger.setLevel`, read by every
|
|
28
|
+
* `AbstractLogger` at emit time), so applying it is an *application* lifecycle
|
|
29
|
+
* event. Applying it from a logger's `@postConstruct` instead puts a global side
|
|
30
|
+
* effect on the construction of an object whose singleton scope is the
|
|
31
|
+
* per-diagram container, which costs two things:
|
|
32
|
+
*
|
|
33
|
+
* - the `onPreferenceChanged` subscription is never disposed AND is re-created
|
|
34
|
+
* for every diagram container, so opening N diagrams leaves N live listeners
|
|
35
|
+
* holding N loggers;
|
|
36
|
+
* - with no diagram open the preference is never applied at all, so anything
|
|
37
|
+
* logging before the first diagram runs at the default threshold.
|
|
38
|
+
*
|
|
39
|
+
* Bound by {@link bindLogLevelPreference} in the **frontend** container — a
|
|
40
|
+
* diagram container is a child and cannot contribute to the parent's
|
|
41
|
+
* multi-binding.
|
|
42
|
+
*/
|
|
43
|
+
@injectable()
|
|
44
|
+
export class LogLevelPreferenceContribution implements FrontendApplicationContribution {
|
|
45
|
+
@inject(LogLevelPreference) protected readonly preferenceName!: string;
|
|
46
|
+
@inject(PreferenceService) protected readonly preferences!: PreferenceService;
|
|
47
|
+
@inject(ILogger) protected readonly logger!: ILogger;
|
|
48
|
+
|
|
49
|
+
/** Holds the single preference subscription. `DisposableCollection` disposes a
|
|
50
|
+
* push-after-dispose immediately, so a subscription that lands after
|
|
51
|
+
* {@link onStop} cannot outlive the application either. */
|
|
52
|
+
protected readonly toDispose = new DisposableCollection();
|
|
53
|
+
/** Guards against a second subscription if `onStart` is ever invoked twice —
|
|
54
|
+
* a duplicated listener is exactly the failure this contribution exists to
|
|
55
|
+
* prevent. */
|
|
56
|
+
protected subscribed = false;
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Returns `void` rather than the promise, so `FrontendApplication.start` does
|
|
60
|
+
* NOT await it.
|
|
61
|
+
*
|
|
62
|
+
* This is load-bearing: `PreferenceService.ready` resolves only once the
|
|
63
|
+
* preference providers are initialised, which happens as part of the same
|
|
64
|
+
* startup sequence that runs the contributions, so awaiting it here deadlocks
|
|
65
|
+
* the frontend on its preload splash. The threshold is therefore applied as
|
|
66
|
+
* soon as preferences are ready, which is early but not synchronously before
|
|
67
|
+
* the first possible log line.
|
|
68
|
+
*/
|
|
69
|
+
onStart(): void {
|
|
70
|
+
void this.applyWhenReady();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The awaited body of {@link onStart}, separated so it can be driven directly
|
|
74
|
+
* in tests without going through the non-awaited lifecycle hook. */
|
|
75
|
+
protected async applyWhenReady(): Promise<void> {
|
|
76
|
+
try {
|
|
77
|
+
await this.preferences.ready;
|
|
78
|
+
this.applyLevel();
|
|
79
|
+
if (this.subscribed) {
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
this.subscribed = true;
|
|
83
|
+
this.toDispose.push(
|
|
84
|
+
this.preferences.onPreferenceChanged(event => {
|
|
85
|
+
if (event.preferenceName === this.preferenceName) {
|
|
86
|
+
this.applyLevel();
|
|
87
|
+
}
|
|
88
|
+
})
|
|
89
|
+
);
|
|
90
|
+
} catch (err) {
|
|
91
|
+
// Reported, not swallowed: an unapplied threshold silently hides every
|
|
92
|
+
// diagnostic below the default, which reads as "the feature is broken".
|
|
93
|
+
this.logger.error(`Failed to apply the log threshold from '${this.preferenceName}'`, err);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
onStop(): void {
|
|
98
|
+
this.toDispose.dispose();
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Read the preference and apply it. An unparseable or unset value leaves the
|
|
103
|
+
* current threshold alone rather than resetting it to a default — the value may
|
|
104
|
+
* legitimately have been set by another source (an env baseline, a test).
|
|
105
|
+
*
|
|
106
|
+
* Override for a custom preference→level mapping.
|
|
107
|
+
*/
|
|
108
|
+
protected applyLevel(): void {
|
|
109
|
+
const level = parseLogLevel(this.preferences.get<string>(this.preferenceName));
|
|
110
|
+
if (level) {
|
|
111
|
+
Logger.setLevel(level);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Bind {@link LogLevelPreferenceContribution} for `preferenceName`.
|
|
118
|
+
*
|
|
119
|
+
* Call from a **frontend** container module. Adopters using
|
|
120
|
+
* `AbstractHydraniumGlspTheiaFrontendModule` set its `logLevelPreference` field
|
|
121
|
+
* instead and the base calls this for them.
|
|
122
|
+
*/
|
|
123
|
+
export function bindLogLevelPreference(bind: interfaces.Bind, preferenceName: string): void {
|
|
124
|
+
bind(LogLevelPreference).toConstantValue(preferenceName);
|
|
125
|
+
bind(LogLevelPreferenceContribution).toSelf().inSingletonScope();
|
|
126
|
+
bind(FrontendApplicationContribution).toService(LogLevelPreferenceContribution);
|
|
127
|
+
}
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
/********************************************************************************
|
|
2
|
+
* Copyright (c) 2026 CrossBreeze, EclipseSource and others.
|
|
3
|
+
*
|
|
4
|
+
* This program and the accompanying materials are made available under the
|
|
5
|
+
* terms of the MIT License which is available in the project root.
|
|
6
|
+
*
|
|
7
|
+
* SPDX-License-Identifier: MIT
|
|
8
|
+
********************************************************************************/
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
formatLatencyReport,
|
|
12
|
+
type DataServerDiagnosticsProtocol,
|
|
13
|
+
type HostDiagnosticsProtocol,
|
|
14
|
+
type StartProfilingArgs
|
|
15
|
+
} from '@hydranium/protocol';
|
|
16
|
+
import { captureBrowserRuntime, formatBrowserRuntime } from './browser-capture';
|
|
17
|
+
import { CommandContribution, MessageService, type Command, type CommandRegistry } from '@theia/core';
|
|
18
|
+
import { inject, injectable, optional, type interfaces } from '@theia/core/shared/inversify';
|
|
19
|
+
import { OutputChannelManager, type OutputChannel } from '@theia/output/lib/browser/output-channel';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Per-product branding for {@link MemoryDiagnosticsContribution}. Bound by the
|
|
23
|
+
* adopter so the command ids, palette category, and output channel carry the
|
|
24
|
+
* product name. The framework supplies the behaviour; only these strings vary.
|
|
25
|
+
*/
|
|
26
|
+
export interface MemoryDiagnosticsOptions {
|
|
27
|
+
/** Prefix for the registered command ids: `<commandIdPrefix>.dumpServerState`. */
|
|
28
|
+
readonly commandIdPrefix: string;
|
|
29
|
+
/** Command-palette category. */
|
|
30
|
+
readonly category: string;
|
|
31
|
+
/** Output channel the full snapshots are appended to (View > Output). */
|
|
32
|
+
readonly channelName: string;
|
|
33
|
+
}
|
|
34
|
+
export const MemoryDiagnosticsOptions = Symbol('MemoryDiagnosticsOptions');
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The server-process diagnostics the commands call. Equals
|
|
38
|
+
* {@link DataServerDiagnosticsProtocol} (`dumpServerState` / `writeHeapSnapshot`
|
|
39
|
+
* / `dumpPodMemory`); the adopter binds its data-server frontend proxy (which
|
|
40
|
+
* exposes those methods after connecting) to this symbol. Kept as a distinct
|
|
41
|
+
* symbol so the contribution stays decoupled from any concrete frontend type.
|
|
42
|
+
*/
|
|
43
|
+
export type MemoryDiagnosticsService = DataServerDiagnosticsProtocol;
|
|
44
|
+
export const MemoryDiagnosticsService = Symbol('MemoryDiagnosticsService');
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Optional host (parent) process diagnostics — the Theia backend itself, reached
|
|
48
|
+
* by an ordinary in-process RPC service ({@link HostDiagnosticsProtocol}), NOT
|
|
49
|
+
* the data-server socket. When bound, the contribution adds the "Dump Backend
|
|
50
|
+
* State" and "Write Heap Snapshot (Backend)" commands; left unbound, those two
|
|
51
|
+
* commands are simply not registered. `@hydranium/data-client-theia`'s
|
|
52
|
+
* `bindHostDiagnostics` binds the frontend proxy.
|
|
53
|
+
*/
|
|
54
|
+
export type HostMemoryDiagnosticsService = HostDiagnosticsProtocol;
|
|
55
|
+
export const HostMemoryDiagnosticsService = Symbol('HostMemoryDiagnosticsService');
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Memory-diagnostics commands, one per layer reachable from the frontend. Full
|
|
59
|
+
* multi-line snapshots are appended to the configured output channel; a one-line
|
|
60
|
+
* summary is toasted.
|
|
61
|
+
* - "Dump Server State" - the data-server process (heap/rss/event-loop/CPU/documents), via RPC.
|
|
62
|
+
* - "Dump Pod Memory" - cgroup current/peak/limit + process-tree RSS (the figure that OOMs a pod), via RPC.
|
|
63
|
+
* - "Dump Frontend State" - the browser tab's JS heap (Chrome `performance.memory`), client-side.
|
|
64
|
+
* - "Write Heap Snapshot" - a V8 heap snapshot of the data-server process, via RPC.
|
|
65
|
+
* - "Start/Stop Profiling" - a windowed CPU/allocation/GC/event-loop sampled profile of the data-server, via RPC.
|
|
66
|
+
* - "Record Performance Profile" - the same, wrapped over a fixed window (start, wait, stop, report).
|
|
67
|
+
* - "Dump RPC/LSP Latency" - per-method latency/throughput collected on the server, via RPC.
|
|
68
|
+
*
|
|
69
|
+
* Generic: the adopter binds {@link MemoryDiagnosticsOptions} (branding) and
|
|
70
|
+
* {@link MemoryDiagnosticsService} (its connected data-server frontend) and adds
|
|
71
|
+
* this class as a `CommandContribution` — see {@link bindMemoryDiagnostics}.
|
|
72
|
+
*/
|
|
73
|
+
@injectable()
|
|
74
|
+
export class MemoryDiagnosticsContribution implements CommandContribution {
|
|
75
|
+
@inject(MemoryDiagnosticsService) protected readonly diagnostics: MemoryDiagnosticsService;
|
|
76
|
+
@inject(MemoryDiagnosticsOptions) protected readonly options: MemoryDiagnosticsOptions;
|
|
77
|
+
@inject(MessageService) protected readonly messageService: MessageService;
|
|
78
|
+
@inject(OutputChannelManager) protected readonly channels: OutputChannelManager;
|
|
79
|
+
@inject(HostMemoryDiagnosticsService) @optional() protected readonly hostDiagnostics?: HostMemoryDiagnosticsService;
|
|
80
|
+
|
|
81
|
+
/** Dimensions the profiling commands capture — the server samples only what is named. */
|
|
82
|
+
protected readonly profileDimensions: StartProfilingArgs = { cpu: true, allocation: true, gc: true, eventLoopDelay: true };
|
|
83
|
+
/** Window length for the "Record Performance Profile" command. */
|
|
84
|
+
protected readonly recordDurationMs = 10_000;
|
|
85
|
+
/**
|
|
86
|
+
* Label of the stop-profiling command, and of the action its start toast
|
|
87
|
+
* offers. One field for both: two literals naming one command can be
|
|
88
|
+
* localized apart, leaving a button or an instruction that names a command
|
|
89
|
+
* the user cannot find.
|
|
90
|
+
*/
|
|
91
|
+
protected readonly stopProfilingLabel: string = 'Stop Profiling (Server)';
|
|
92
|
+
|
|
93
|
+
registerCommands(registry: CommandRegistry): void {
|
|
94
|
+
const { category } = this.options;
|
|
95
|
+
registry.registerCommand(this.command('dumpServerState', 'Dump Server State', category), {
|
|
96
|
+
execute: () => this.report('server state', () => this.diagnostics.dumpServerState({ label: new Date().toISOString() }), ['heap'])
|
|
97
|
+
});
|
|
98
|
+
registry.registerCommand(this.command('dumpPodMemory', 'Dump Pod Memory', category), {
|
|
99
|
+
execute: () => this.report('pod memory', () => this.diagnostics.dumpPodMemory(), ['current', 'rss sum'])
|
|
100
|
+
});
|
|
101
|
+
registry.registerCommand(this.command('dumpFrontendState', 'Dump Frontend State', category), {
|
|
102
|
+
execute: () => this.dumpFrontendState()
|
|
103
|
+
});
|
|
104
|
+
registry.registerCommand(this.command('writeHeapSnapshot', 'Write Heap Snapshot (Server)', category), {
|
|
105
|
+
execute: () => this.writeSnapshot('server', label => this.diagnostics.writeHeapSnapshot({ label }))
|
|
106
|
+
});
|
|
107
|
+
// Sampled profiling of the server process — sampling does NOT pause it (unlike
|
|
108
|
+
// the heap snapshot). Start/Stop are the manual pair; Record wraps a fixed window.
|
|
109
|
+
registry.registerCommand(this.command('startProfiling', 'Start Profiling (Server)', category), {
|
|
110
|
+
execute: () => this.startProfiling()
|
|
111
|
+
});
|
|
112
|
+
registry.registerCommand(this.command('stopProfiling', this.stopProfilingLabel, category), {
|
|
113
|
+
execute: () => this.stopProfiling()
|
|
114
|
+
});
|
|
115
|
+
registry.registerCommand(
|
|
116
|
+
this.command('recordProfile', `Record Performance Profile (Server, ${Math.round(this.recordDurationMs / 1000)}s)`, category),
|
|
117
|
+
{
|
|
118
|
+
execute: () => this.recordProfile()
|
|
119
|
+
}
|
|
120
|
+
);
|
|
121
|
+
registry.registerCommand(this.command('dumpLatency', 'Dump RPC/LSP Latency (Server)', category), {
|
|
122
|
+
execute: () => this.report('RPC/LSP latency', async () => formatLatencyReport(await this.diagnostics.getLatency()), ['window'])
|
|
123
|
+
});
|
|
124
|
+
// Host (Theia backend) process commands — registered only when the
|
|
125
|
+
// optional host-diagnostics service is bound (see HostMemoryDiagnosticsService).
|
|
126
|
+
const hostDiagnostics = this.hostDiagnostics;
|
|
127
|
+
if (hostDiagnostics) {
|
|
128
|
+
registry.registerCommand(this.command('dumpBackendState', 'Dump Backend State', category), {
|
|
129
|
+
execute: () => this.report('backend state', () => hostDiagnostics.dumpHostState({ label: new Date().toISOString() }), ['heap'])
|
|
130
|
+
});
|
|
131
|
+
registry.registerCommand(this.command('writeBackendHeapSnapshot', 'Write Heap Snapshot (Backend)', category), {
|
|
132
|
+
execute: () => this.writeSnapshot('backend', label => hostDiagnostics.writeHostHeapSnapshot({ label }))
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
protected command(id: string, label: string, category: string): Command {
|
|
138
|
+
return { id: `${this.options.commandIdPrefix}.${id}`, label, category };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Run a snapshot call, append the full result to the channel, toast the first matching summary line. */
|
|
142
|
+
protected async report(what: string, produce: () => Promise<string>, summaryKeys: string[]): Promise<void> {
|
|
143
|
+
try {
|
|
144
|
+
const snapshot = await produce();
|
|
145
|
+
this.channel().appendLine(snapshot);
|
|
146
|
+
this.channel().appendLine('');
|
|
147
|
+
this.messageService.info(this.summarize(snapshot, what, summaryKeys), { timeout: 5000 });
|
|
148
|
+
} catch (error) {
|
|
149
|
+
this.messageService.error(`Failed to dump ${what}: ${this.errorMessage(error)}`);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
protected async writeSnapshot(target: string, produce: (label: string) => Promise<string>): Promise<void> {
|
|
154
|
+
try {
|
|
155
|
+
this.messageService.info(`Writing ${target} heap snapshot — this briefly pauses that process...`, { timeout: 3000 });
|
|
156
|
+
const filePath = await produce(new Date().toISOString());
|
|
157
|
+
this.channel().appendLine(`Heap snapshot (${target}) written to ${filePath}`);
|
|
158
|
+
this.messageService.info(`Heap snapshot (${target}) written to ${filePath}`, { timeout: 8000 });
|
|
159
|
+
} catch (error) {
|
|
160
|
+
this.messageService.error(`Failed to write ${target} heap snapshot: ${this.errorMessage(error)}`);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Begin an open-ended capture; the toast's stop action ends it and reports. */
|
|
165
|
+
protected async startProfiling(): Promise<void> {
|
|
166
|
+
try {
|
|
167
|
+
await this.diagnostics.startProfiling(this.profileDimensions);
|
|
168
|
+
} catch (error) {
|
|
169
|
+
this.messageService.error(`Failed to start profiling: ${this.errorMessage(error)}`);
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
// No timeout: the capture runs as long as the user wants it to, and an
|
|
173
|
+
// auto-dismissing toast takes the stop affordance with it. Awaiting the
|
|
174
|
+
// toast is what keeps the action live, so this resolves only once the
|
|
175
|
+
// user acts on it or dismisses it.
|
|
176
|
+
const chosen = await this.messageService.info(
|
|
177
|
+
'Server profiling started — sampling does not stop the process.',
|
|
178
|
+
{ timeout: 0 },
|
|
179
|
+
this.stopProfilingLabel
|
|
180
|
+
);
|
|
181
|
+
if (chosen === this.stopProfilingLabel) {
|
|
182
|
+
await this.stopProfiling();
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** End the capture and report it. Shared, so the toast action and the command cannot diverge in behaviour. */
|
|
187
|
+
protected stopProfiling(): Promise<void> {
|
|
188
|
+
return this.report('profile', () => this.diagnostics.stopProfiling({ label: new Date().toISOString() }), ['duration']);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Capture a fixed-length window: start, wait, stop, and report the result. */
|
|
192
|
+
protected async recordProfile(): Promise<void> {
|
|
193
|
+
try {
|
|
194
|
+
await this.diagnostics.startProfiling(this.profileDimensions);
|
|
195
|
+
} catch (error) {
|
|
196
|
+
this.messageService.error(`Failed to start profiling: ${this.errorMessage(error)}`);
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
this.messageService.info(`Recording a ${Math.round(this.recordDurationMs / 1000)}s server performance profile...`, { timeout: 4000 });
|
|
200
|
+
await this.delay(this.recordDurationMs);
|
|
201
|
+
await this.stopProfiling();
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Isolated so tests can drive the record window without a real timer. */
|
|
205
|
+
protected delay(ms: number): Promise<void> {
|
|
206
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
protected async dumpFrontendState(): Promise<void> {
|
|
210
|
+
const text = formatBrowserRuntime(await captureBrowserRuntime());
|
|
211
|
+
this.channel().appendLine(text);
|
|
212
|
+
this.messageService.info(text, { timeout: 6000 });
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
protected channel(): OutputChannel {
|
|
216
|
+
const channel = this.channels.getChannel(this.options.channelName);
|
|
217
|
+
channel.show({ preserveFocus: true });
|
|
218
|
+
return channel;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** Pull the first line starting with one of `keys` for a one-line toast; full text is in the channel. */
|
|
222
|
+
protected summarize(snapshot: string, what: string, keys: string[]): string {
|
|
223
|
+
const lines = snapshot.split('\n');
|
|
224
|
+
for (const key of keys) {
|
|
225
|
+
const line = lines.find(entry => entry.trim().startsWith(key));
|
|
226
|
+
if (line) {
|
|
227
|
+
return `Captured ${what} —${line.replace(new RegExp(`^\\s*${key}\\s*`), ` ${key} `)}`;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
return `Captured ${what} (see the ${this.options.channelName} output channel)`;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
protected errorMessage(error: unknown): string {
|
|
234
|
+
return error instanceof Error ? error.message : String(error);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Bind {@link MemoryDiagnosticsContribution} as a `CommandContribution` with the
|
|
240
|
+
* given branding. The adopter still binds {@link MemoryDiagnosticsService} to its
|
|
241
|
+
* connected data-server frontend separately (the framework cannot — the concrete
|
|
242
|
+
* frontend is adopter-defined). Call from a Theia frontend `ContainerModule`.
|
|
243
|
+
*/
|
|
244
|
+
export function bindMemoryDiagnostics(bind: interfaces.Bind, options: MemoryDiagnosticsOptions): void {
|
|
245
|
+
bind(MemoryDiagnosticsOptions).toConstantValue(options);
|
|
246
|
+
bind(MemoryDiagnosticsContribution).toSelf().inSingletonScope();
|
|
247
|
+
bind(CommandContribution).toService(MemoryDiagnosticsContribution);
|
|
248
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/********************************************************************************
|
|
2
|
+
* Copyright (c) 2026 CrossBreeze, EclipseSource and others.
|
|
3
|
+
*
|
|
4
|
+
* This program and the accompanying materials are made available under the
|
|
5
|
+
* terms of the MIT License which is available in the project root.
|
|
6
|
+
*
|
|
7
|
+
* SPDX-License-Identifier: MIT
|
|
8
|
+
********************************************************************************/
|
|
9
|
+
|
|
10
|
+
// Root barrel. Every surface this package has is environment-bound, so the root
|
|
11
|
+
// exposes nothing directly rather than privileging one tier: node-side
|
|
12
|
+
// primitives (the socket-forwarding connection-handler base, which imports
|
|
13
|
+
// `node:net`) via `@hydranium/client-theia/node`; browser-side primitives (the
|
|
14
|
+
// preference-driven Output-channel logger, the renderer-runtime capture, the
|
|
15
|
+
// memory-diagnostics contribution) via `@hydranium/client-theia/browser`;
|
|
16
|
+
// shared test doubles via `@hydranium/client-theia/testing`. Re-exporting
|
|
17
|
+
// either tier here would drag a `node:net` import or a DOM type into the entry
|
|
18
|
+
// a consumer of the *other* tier resolves first.
|
|
19
|
+
export {};
|