@celilo/console-server 0.6.0 → 0.6.1

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
@@ -66,8 +66,15 @@ export interface UpstreamOptions {
66
66
  const EXIT_DENIED = 126;
67
67
 
68
68
  interface CacheEntry {
69
+ /** When the value SETTLED. Meaningless while `pending`. */
69
70
  at: number;
70
- value: unknown;
71
+ /**
72
+ * The promise, not the resolved value — so concurrent callers for the same
73
+ * argv join one request instead of each starting their own.
74
+ */
75
+ value: Promise<unknown>;
76
+ /** In flight. A pending entry is joined regardless of the TTL. */
77
+ pending: boolean;
71
78
  }
72
79
 
73
80
  export class Upstream {
@@ -90,10 +97,41 @@ export class Upstream {
90
97
  async read<T>(argv: readonly string[]): Promise<T> {
91
98
  const key = argv.join(' ');
92
99
  const hit = this.cache.get(key);
93
- if (hit && this.now() - hit.at < this.ttl) return hit.value as T;
100
+ // A PENDING entry is joined whatever the TTL says. This cache used to store
101
+ // the resolved value and `await` before setting it, so every caller that
102
+ // arrived while a read was in flight missed and started its own. The console
103
+ // polls every 5s and mounts several panels at once, and each panel opens its
104
+ // OWN ssh connection because ControlMaster is deliberately off (celilo#921),
105
+ // so identical argv went to celilo three or four times per cycle. Measured
106
+ // on the live console 2026-09-27: 28 `console:status` ops against 18
107
+ // `alerts:list` + 10 `backup:list`, with panels at 12-14s against a 15s
108
+ // client timeout.
109
+ //
110
+ // Joining only on a fresh TTL would not fix it: a read takes ~4s here and
111
+ // the TTL is 2s, so the entry expires WHILE IN FLIGHT and the next caller
112
+ // duplicates it anyway. With reads slower than the TTL that is the common
113
+ // case, not an edge one — hence `pending`.
114
+ if (hit && (hit.pending || this.now() - hit.at < this.ttl)) return hit.value as Promise<T>;
115
+
116
+ const value = this.runJson<T>(argv);
117
+ const entry: CacheEntry = { at: this.now(), value, pending: true };
118
+ this.cache.set(key, entry);
119
+
120
+ value.then(
121
+ () => {
122
+ // The TTL runs from when the value SETTLED, not when the request
123
+ // started; otherwise a read slower than the TTL is stale on arrival.
124
+ entry.at = this.now();
125
+ entry.pending = false;
126
+ },
127
+ () => {
128
+ // A failed read must not be served for the rest of the window, and must
129
+ // not leave a permanently-pending entry that every later caller joins.
130
+ // Retry it; do not remember it.
131
+ if (this.cache.get(key) === entry) this.cache.delete(key);
132
+ },
133
+ );
94
134
 
95
- const value = await this.runJson<T>(argv);
96
- this.cache.set(key, { at: this.now(), value });
97
135
  return value;
98
136
  }
99
137