@celilo/console-server 0.6.1 → 0.6.2

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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/upstream.ts +58 -16
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@celilo/console-server",
3
- "version": "0.6.1",
3
+ "version": "0.6.2",
4
4
  "description": "HTTP surface for the celilo web console. Serves the SPA and hosts the tsrpc API. Holds no database: it reaches celilo-mgr over the SSH remote API as a read-only principal.",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
package/src/upstream.ts CHANGED
@@ -28,7 +28,12 @@
28
28
  */
29
29
 
30
30
  import type { DisplayWriter } from '@celilo/cli-display';
31
- import { type RemoteTransport, runRemoteClient, sshTransportWith } from '@celilo/core';
31
+ import {
32
+ type RemoteSession,
33
+ type RemoteTransport,
34
+ openRemoteSession,
35
+ sshTransportWith,
36
+ } from '@celilo/core';
32
37
 
33
38
  /** Why a read could not be served. Every case is renderable; none is silence. */
34
39
  export type UnavailableReason =
@@ -65,6 +70,8 @@ export interface UpstreamOptions {
65
70
  /** Exit code the API uses for "this principal may not do that". */
66
71
  const EXIT_DENIED = 126;
67
72
 
73
+ type RemoteOutcomeOf = Awaited<ReturnType<RemoteSession['run']>>;
74
+
68
75
  interface CacheEntry {
69
76
  /** When the value SETTLED. Meaningless while `pending`. */
70
77
  at: number;
@@ -82,6 +89,22 @@ export class Upstream {
82
89
  private readonly ttl: number;
83
90
  private readonly now: () => number;
84
91
 
92
+ /**
93
+ * One connection, reused across reads.
94
+ *
95
+ * `api-serve` is a persistent server and its start-up dominates a read: on
96
+ * celilo-mgr it greets at ~1.8s and then answers commands in 1.25s and 0.06s
97
+ * on the SAME connection. Opening a fresh one per read re-paid that every
98
+ * time, which is most of why a console panel cost 5-11s against a 15s client
99
+ * timeout.
100
+ *
101
+ * ONE session, not a pool: the server runs a single command at a time
102
+ * (`serve.ts` holds one `commandRunning` slot), so a pool would not raise
103
+ * throughput, only connection count — and connection count is exactly what
104
+ * celilo#921 was about.
105
+ */
106
+ private session: RemoteSession | null = null;
107
+
85
108
  constructor(private readonly options: UpstreamOptions) {
86
109
  this.ttl = options.cacheTtlMs ?? 2_000;
87
110
  this.now = options.now ?? Date.now;
@@ -154,6 +177,34 @@ export class Upstream {
154
177
  this.cache.clear();
155
178
  }
156
179
 
180
+ private ensureSession(): RemoteSession {
181
+ const live = this.session;
182
+ if (live !== null && !live.closed) return live;
183
+ const session = openRemoteSession(this.options.dest, {
184
+ openTransport: this.options.openTransport ?? sshTransportWith(this.options.identityFile),
185
+ // Reached only if celilo asks the console a question. Nothing here can
186
+ // answer one, so say so loudly rather than parking a request forever.
187
+ // Session-scoped now rather than per-command, so it cannot name the argv;
188
+ // the reason is the same for every command the console issues.
189
+ renderInterview: () => {
190
+ throw new UpstreamUnavailable(
191
+ 'interview',
192
+ [],
193
+ 'A command raised an interview question. The console has no responder and never invokes operations that can ask one.',
194
+ );
195
+ },
196
+ onBlocked: 'return',
197
+ });
198
+ this.session = session;
199
+ return session;
200
+ }
201
+
202
+ /** Drop the connection. The next read opens a fresh one. */
203
+ closeSession(): void {
204
+ this.session?.close();
205
+ this.session = null;
206
+ }
207
+
157
208
  private async runJson<T>(argv: readonly string[]): Promise<T> {
158
209
  const output = await this.runText(argv);
159
210
  try {
@@ -177,23 +228,14 @@ export class Upstream {
177
228
  isTTY: false,
178
229
  };
179
230
 
180
- let outcome: Awaited<ReturnType<typeof runRemoteClient>>;
231
+ let outcome: RemoteOutcomeOf;
181
232
  try {
182
- outcome = await runRemoteClient(this.options.dest, [...argv], {
183
- openTransport: this.options.openTransport ?? sshTransportWith(this.options.identityFile),
184
- out,
185
- // Reached only if celilo asks the console a question. Nothing here can
186
- // answer one, so say so loudly rather than parking a request forever.
187
- renderInterview: () => {
188
- throw new UpstreamUnavailable(
189
- 'interview',
190
- argv,
191
- `\`${argv.join(' ')}\` raised an interview question. The console has no responder and never invokes operations that can ask one.`,
192
- );
193
- },
194
- onBlocked: 'return',
195
- });
233
+ outcome = await this.ensureSession().run(argv, { out });
196
234
  } catch (error) {
235
+ // The connection is suspect once a read has thrown through it — the
236
+ // server may have restarted or the transport died mid-command. Drop it so
237
+ // the NEXT read reconnects instead of writing into a pipe nobody reads.
238
+ this.closeSession();
197
239
  if (error instanceof UpstreamUnavailable) throw error;
198
240
  throw new UpstreamUnavailable(
199
241
  'unreachable',