@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
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
231
|
+
let outcome: RemoteOutcomeOf;
|
|
143
232
|
try {
|
|
144
|
-
outcome = await
|
|
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',
|