@celilo/console-server 0.6.0 → 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.
package/spa/index.html CHANGED
@@ -4,7 +4,15 @@
4
4
  <meta charset="utf-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1" />
6
6
  <title>celilo</title>
7
- <script type="module" crossorigin src="/assets/index-xOtoHA1j.js"></script>
7
+ <!-- Inline so there is no file to ship and no second request. The glyph is
8
+ drawn by the VIEWER's emoji font, so it will not look identical across
9
+ platforms, and a system without the woman-technologist ZWJ sequence
10
+ falls back to two glyphs side by side rather than one. -->
11
+ <link
12
+ rel="icon"
13
+ href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>👩‍💻</text></svg>"
14
+ />
15
+ <script type="module" crossorigin src="/assets/index-bR5M5myY.js"></script>
8
16
  <link rel="stylesheet" crossorigin href="/assets/index-eLF_dgP-.css">
9
17
  </head>
10
18
  <body>
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,9 +70,18 @@ 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 {
76
+ /** When the value SETTLED. Meaningless while `pending`. */
69
77
  at: number;
70
- value: unknown;
78
+ /**
79
+ * The promise, not the resolved value — so concurrent callers for the same
80
+ * argv join one request instead of each starting their own.
81
+ */
82
+ value: Promise<unknown>;
83
+ /** In flight. A pending entry is joined regardless of the TTL. */
84
+ pending: boolean;
71
85
  }
72
86
 
73
87
  export class Upstream {
@@ -75,6 +89,22 @@ export class Upstream {
75
89
  private readonly ttl: number;
76
90
  private readonly now: () => number;
77
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
+
78
108
  constructor(private readonly options: UpstreamOptions) {
79
109
  this.ttl = options.cacheTtlMs ?? 2_000;
80
110
  this.now = options.now ?? Date.now;
@@ -90,10 +120,41 @@ export class Upstream {
90
120
  async read<T>(argv: readonly string[]): Promise<T> {
91
121
  const key = argv.join(' ');
92
122
  const hit = this.cache.get(key);
93
- if (hit && this.now() - hit.at < this.ttl) return hit.value as T;
123
+ // A PENDING entry is joined whatever the TTL says. This cache used to store
124
+ // the resolved value and `await` before setting it, so every caller that
125
+ // arrived while a read was in flight missed and started its own. The console
126
+ // polls every 5s and mounts several panels at once, and each panel opens its
127
+ // OWN ssh connection because ControlMaster is deliberately off (celilo#921),
128
+ // so identical argv went to celilo three or four times per cycle. Measured
129
+ // on the live console 2026-09-27: 28 `console:status` ops against 18
130
+ // `alerts:list` + 10 `backup:list`, with panels at 12-14s against a 15s
131
+ // client timeout.
132
+ //
133
+ // Joining only on a fresh TTL would not fix it: a read takes ~4s here and
134
+ // the TTL is 2s, so the entry expires WHILE IN FLIGHT and the next caller
135
+ // duplicates it anyway. With reads slower than the TTL that is the common
136
+ // case, not an edge one — hence `pending`.
137
+ if (hit && (hit.pending || this.now() - hit.at < this.ttl)) return hit.value as Promise<T>;
138
+
139
+ const value = this.runJson<T>(argv);
140
+ const entry: CacheEntry = { at: this.now(), value, pending: true };
141
+ this.cache.set(key, entry);
142
+
143
+ value.then(
144
+ () => {
145
+ // The TTL runs from when the value SETTLED, not when the request
146
+ // started; otherwise a read slower than the TTL is stale on arrival.
147
+ entry.at = this.now();
148
+ entry.pending = false;
149
+ },
150
+ () => {
151
+ // A failed read must not be served for the rest of the window, and must
152
+ // not leave a permanently-pending entry that every later caller joins.
153
+ // Retry it; do not remember it.
154
+ if (this.cache.get(key) === entry) this.cache.delete(key);
155
+ },
156
+ );
94
157
 
95
- const value = await this.runJson<T>(argv);
96
- this.cache.set(key, { at: this.now(), value });
97
158
  return value;
98
159
  }
99
160
 
@@ -116,6 +177,34 @@ export class Upstream {
116
177
  this.cache.clear();
117
178
  }
118
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
+
119
208
  private async runJson<T>(argv: readonly string[]): Promise<T> {
120
209
  const output = await this.runText(argv);
121
210
  try {
@@ -139,23 +228,14 @@ export class Upstream {
139
228
  isTTY: false,
140
229
  };
141
230
 
142
- let outcome: Awaited<ReturnType<typeof runRemoteClient>>;
231
+ let outcome: RemoteOutcomeOf;
143
232
  try {
144
- outcome = await runRemoteClient(this.options.dest, [...argv], {
145
- openTransport: this.options.openTransport ?? sshTransportWith(this.options.identityFile),
146
- out,
147
- // Reached only if celilo asks the console a question. Nothing here can
148
- // answer one, so say so loudly rather than parking a request forever.
149
- renderInterview: () => {
150
- throw new UpstreamUnavailable(
151
- 'interview',
152
- argv,
153
- `\`${argv.join(' ')}\` raised an interview question. The console has no responder and never invokes operations that can ask one.`,
154
- );
155
- },
156
- onBlocked: 'return',
157
- });
233
+ outcome = await this.ensureSession().run(argv, { out });
158
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();
159
239
  if (error instanceof UpstreamUnavailable) throw error;
160
240
  throw new UpstreamUnavailable(
161
241
  'unreachable',