@telorun/runner-core 0.8.2 → 0.10.0
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/README.md +33 -2
- package/dist/backend.d.ts +107 -23
- package/dist/backend.d.ts.map +1 -1
- package/dist/config.d.ts +56 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +36 -0
- package/dist/config.js.map +1 -1
- package/dist/contract.d.ts +210 -15
- package/dist/contract.d.ts.map +1 -1
- package/dist/contract.js +9 -0
- package/dist/contract.js.map +1 -1
- package/dist/debug/ports-resolved.d.ts +26 -0
- package/dist/debug/ports-resolved.d.ts.map +1 -0
- package/dist/debug/ports-resolved.js +42 -0
- package/dist/debug/ports-resolved.js.map +1 -0
- package/dist/debug/relay.d.ts.map +1 -1
- package/dist/debug/relay.js +34 -6
- package/dist/debug/relay.js.map +1 -1
- package/dist/debug/run-projection.d.ts +53 -0
- package/dist/debug/run-projection.d.ts.map +1 -0
- package/dist/debug/run-projection.js +115 -0
- package/dist/debug/run-projection.js.map +1 -0
- package/dist/index.d.ts +8 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -1
- package/dist/index.js.map +1 -1
- package/dist/routes/apps.d.ts +1 -1
- package/dist/routes/apps.d.ts.map +1 -1
- package/dist/routes/apps.js +14 -4
- package/dist/routes/apps.js.map +1 -1
- package/dist/routes/io.d.ts.map +1 -1
- package/dist/routes/io.js +42 -9
- package/dist/routes/io.js.map +1 -1
- package/dist/routes/session-start.d.ts +18 -13
- package/dist/routes/session-start.d.ts.map +1 -1
- package/dist/routes/session-start.js +95 -14
- package/dist/routes/session-start.js.map +1 -1
- package/dist/routes/sessions.d.ts +11 -1
- package/dist/routes/sessions.d.ts.map +1 -1
- package/dist/routes/sessions.js +465 -5
- package/dist/routes/sessions.js.map +1 -1
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +21 -1
- package/dist/server.js.map +1 -1
- package/dist/session/byte-ring-buffer.d.ts +6 -1
- package/dist/session/byte-ring-buffer.d.ts.map +1 -1
- package/dist/session/byte-ring-buffer.js +2 -2
- package/dist/session/byte-ring-buffer.js.map +1 -1
- package/dist/session/registry.d.ts +144 -13
- package/dist/session/registry.d.ts.map +1 -1
- package/dist/session/registry.js +166 -25
- package/dist/session/registry.js.map +1 -1
- package/dist/session/watch-supervisor.d.ts +42 -0
- package/dist/session/watch-supervisor.d.ts.map +1 -0
- package/dist/session/watch-supervisor.js +99 -0
- package/dist/session/watch-supervisor.js.map +1 -0
- package/dist/session/workspace-app.d.ts +4 -0
- package/dist/session/workspace-app.d.ts.map +1 -0
- package/dist/session/workspace-app.js +26 -0
- package/dist/session/workspace-app.js.map +1 -0
- package/dist/session/workspace-client.d.ts +33 -0
- package/dist/session/workspace-client.d.ts.map +1 -0
- package/dist/session/workspace-client.js +62 -0
- package/dist/session/workspace-client.js.map +1 -0
- package/dist/session/workspace-marker.d.ts +25 -0
- package/dist/session/workspace-marker.d.ts.map +1 -0
- package/dist/session/workspace-marker.js +35 -0
- package/dist/session/workspace-marker.js.map +1 -0
- package/dist/sse/channel.d.ts.map +1 -1
- package/dist/sse/channel.js +4 -0
- package/dist/sse/channel.js.map +1 -1
- package/package.json +4 -3
- package/src/app-catalog.test.ts +49 -1
- package/src/backend.ts +111 -22
- package/src/config.ts +98 -0
- package/src/contract.ts +213 -16
- package/src/debug/ports-resolved.test.ts +66 -0
- package/src/debug/ports-resolved.ts +43 -0
- package/src/debug/relay.ts +32 -4
- package/src/debug/run-projection.test.ts +155 -0
- package/src/debug/run-projection.ts +122 -0
- package/src/index.ts +20 -1
- package/src/routes/apps.ts +14 -5
- package/src/routes/io.ts +58 -12
- package/src/routes/session-start.ts +115 -27
- package/src/routes/sessions.ts +538 -7
- package/src/server.ts +22 -1
- package/src/session/byte-ring-buffer.ts +8 -2
- package/src/session/registry.ts +288 -28
- package/src/session/ring-buffer.test.ts +4 -3
- package/src/session/watch-lifetime.test.ts +203 -0
- package/src/session/watch-supervisor.ts +112 -0
- package/src/session/workspace-app.ts +27 -0
- package/src/session/workspace-client.ts +80 -0
- package/src/session/workspace-marker.test.ts +35 -0
- package/src/session/workspace-marker.ts +39 -0
- package/src/sse/channel.ts +5 -0
- package/workspace-app/telo.yaml +228 -0
package/src/session/registry.ts
CHANGED
|
@@ -1,17 +1,53 @@
|
|
|
1
1
|
import { EventEmitter } from "node:events";
|
|
2
2
|
|
|
3
|
-
import type {
|
|
4
|
-
|
|
3
|
+
import type { DebugFrame } from "@telorun/debug-wire";
|
|
4
|
+
|
|
5
|
+
import type { BackendSession, WorkloadLaunch } from "../backend.js";
|
|
6
|
+
import {
|
|
7
|
+
DEFAULT_APP_NAME,
|
|
8
|
+
isTerminal,
|
|
9
|
+
type ByteStreamTag,
|
|
10
|
+
type IoMode,
|
|
11
|
+
type PortMapping,
|
|
12
|
+
type RunEvent,
|
|
13
|
+
type RunStatus,
|
|
14
|
+
type RunTrigger,
|
|
15
|
+
type SessionMode,
|
|
16
|
+
type WorkspaceCheckpointFile,
|
|
17
|
+
} from "../contract.js";
|
|
5
18
|
import { ByteRingBuffer, type BufferedBytes } from "./byte-ring-buffer.js";
|
|
6
19
|
import { EventRingBuffer, type BufferedEvent } from "./ring-buffer.js";
|
|
7
20
|
|
|
21
|
+
/**
|
|
22
|
+
* One application's terminal and run bookkeeping. The byte channel is keyed
|
|
23
|
+
* `(session, app)` rather than labelled on a merged stream: each app container
|
|
24
|
+
* has its OWN terminal, and merging them would make `/io` unattachable to one.
|
|
25
|
+
*/
|
|
26
|
+
export interface AppChannel {
|
|
27
|
+
readonly name: string;
|
|
28
|
+
readonly io: IoMode;
|
|
29
|
+
readonly byteBuffer: ByteRingBuffer;
|
|
30
|
+
readonly byteEmitter: EventEmitter;
|
|
31
|
+
/** Monotonic per app, starting at 1. Counted here from the kernel lifecycle
|
|
32
|
+
* events the debug stream already carries — the kernel is asked for nothing. */
|
|
33
|
+
generation: number;
|
|
34
|
+
/** Epoch ms the current generation started, for `durationMs` on completion.
|
|
35
|
+
* Null between generations. */
|
|
36
|
+
startedAt: number | null;
|
|
37
|
+
/** The port set this app currently declares. Re-read on every reload, so a
|
|
38
|
+
* `ports:` edit patches the Service and Ingress instead of silently binding
|
|
39
|
+
* a port nothing routes. */
|
|
40
|
+
ports: PortMapping[];
|
|
41
|
+
}
|
|
42
|
+
|
|
8
43
|
export interface SessionEntry {
|
|
9
44
|
readonly sessionId: string;
|
|
10
45
|
readonly createdAt: Date;
|
|
46
|
+
readonly mode: SessionMode;
|
|
11
47
|
readonly buffer: EventRingBuffer;
|
|
12
|
-
readonly byteBuffer: ByteRingBuffer;
|
|
13
48
|
readonly emitter: EventEmitter;
|
|
14
|
-
|
|
49
|
+
/** One channel per application container, insertion-ordered. */
|
|
50
|
+
readonly apps: Map<string, AppChannel>;
|
|
15
51
|
|
|
16
52
|
/** The live backend workload. Null until `start` resolves; the route writes
|
|
17
53
|
* stdin / resize / stop through it. A backend's `writeStdin` is a no-op once
|
|
@@ -21,12 +57,70 @@ export interface SessionEntry {
|
|
|
21
57
|
exitedAt: Date | null;
|
|
22
58
|
userStopped: boolean;
|
|
23
59
|
evictionTimer: NodeJS.Timeout | null;
|
|
60
|
+
|
|
61
|
+
/** Catalog name of the co-resident agent, when one was requested. */
|
|
62
|
+
agent?: string;
|
|
63
|
+
/** Last whole-tree workspace snapshot, pulled on the checkpoint timer and
|
|
64
|
+
* again on suspend. A CACHE, never the only copy: the editor holds the
|
|
65
|
+
* authoritative workspace, so a runner restart losing this costs an upload,
|
|
66
|
+
* not user work. */
|
|
67
|
+
checkpoint: WorkspaceCheckpoint | null;
|
|
68
|
+
/** How the NEXT generation of an app should be attributed. Structural rather
|
|
69
|
+
* than the projection type itself, so the registry stays free of the debug
|
|
70
|
+
* stream it knows nothing about. */
|
|
71
|
+
attribution: RunAttribution | null;
|
|
72
|
+
/** The description this session was launched from, retained so `resume` can
|
|
73
|
+
* build a fresh pod under the same id without a second copy of it. */
|
|
74
|
+
launch: WorkloadLaunch | null;
|
|
75
|
+
/** Live SSE/WS subscriber count. Zero for longer than the idle window is what
|
|
76
|
+
* suspends a watch session. */
|
|
77
|
+
subscribers: number;
|
|
78
|
+
/** Epoch ms the subscriber count last fell to zero; null while someone is
|
|
79
|
+
* attached. */
|
|
80
|
+
idleSince: number | null;
|
|
81
|
+
/** Epoch ms of recent reloads, for the per-session reload rate limit. A watch
|
|
82
|
+
* session reloads on every save, so an unbounded one is a way to keep a pod
|
|
83
|
+
* permanently rebuilding. */
|
|
84
|
+
reloads: number[];
|
|
85
|
+
/** Epoch ms the last workspace checkpoint was pulled. */
|
|
86
|
+
checkpointedAt: number | null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* The run projection as the registry sees it — structural, so the registry stays
|
|
91
|
+
* free of the debug stream it knows nothing about. `expect` is how a route tells
|
|
92
|
+
* it about a generation it is ABOUT to cause: without that, a manual reload
|
|
93
|
+
* would be reported as a watch reload, and the runner would be guessing at the
|
|
94
|
+
* one fact it actually knows.
|
|
95
|
+
*/
|
|
96
|
+
export interface RunAttribution {
|
|
97
|
+
expect(app: string, trigger: RunTrigger): void;
|
|
98
|
+
expectAll(trigger: RunTrigger): void;
|
|
99
|
+
frame(app: string, frame: DebugFrame): void;
|
|
100
|
+
/** Close the open generation because its workload ended — a container that
|
|
101
|
+
* goes away emits no `Kernel.Stopped`, so the backend reports the ending. */
|
|
102
|
+
endGeneration(app: string, outcome: { code?: number; reason?: string }): void;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** A whole-tree snapshot plus the contents needed to re-seed a fresh pod. Whole
|
|
106
|
+
* tree rather than a delta log from the write path: a manifest workspace is
|
|
107
|
+
* small, and one shape is easier to reason about than a replay that has to be
|
|
108
|
+
* correct. */
|
|
109
|
+
export interface WorkspaceCheckpoint {
|
|
110
|
+
takenAt: Date;
|
|
111
|
+
files: WorkspaceCheckpointFile[];
|
|
24
112
|
}
|
|
25
113
|
|
|
26
114
|
export interface RegistryDeps {
|
|
27
115
|
maxSessions: number;
|
|
28
116
|
exitTtlMs: number;
|
|
117
|
+
/** Per APP, not per session — each application container has its own terminal
|
|
118
|
+
* and its own replay buffer. */
|
|
29
119
|
replayBufferBytes: number;
|
|
120
|
+
/** How long a suspended session record is retained before eviction. Bounds
|
|
121
|
+
* accumulation, and is deliberately not the pod deadline: that bounds a POD,
|
|
122
|
+
* so on its own nothing would ever evict a suspended record. */
|
|
123
|
+
suspendedTtlMs?: number;
|
|
30
124
|
}
|
|
31
125
|
|
|
32
126
|
const EVENT_EMITTED = "event";
|
|
@@ -42,6 +136,16 @@ const MAX_PUSH_CHUNK = 64 * 1024;
|
|
|
42
136
|
|
|
43
137
|
export class SessionLimitError extends Error {}
|
|
44
138
|
export class SessionEvictedError extends Error {}
|
|
139
|
+
export class UnknownAppError extends Error {}
|
|
140
|
+
|
|
141
|
+
export interface RegisterArgs {
|
|
142
|
+
sessionId: string;
|
|
143
|
+
mode?: SessionMode;
|
|
144
|
+
agent?: string;
|
|
145
|
+
/** The applications this session runs. Defaults to one app named `app` with a
|
|
146
|
+
* terminal, so a single-app session behaves exactly as before. */
|
|
147
|
+
apps?: Array<{ name: string; io?: IoMode; ports?: PortMapping[] }>;
|
|
148
|
+
}
|
|
45
149
|
|
|
46
150
|
export class SessionRegistry {
|
|
47
151
|
private readonly sessions = new Map<string, SessionEntry>();
|
|
@@ -68,7 +172,7 @@ export class SessionRegistry {
|
|
|
68
172
|
* Creates a fresh registry entry. Callers are responsible for guarding against
|
|
69
173
|
* duplicate insertion. Throws SessionLimitError if we're at capacity.
|
|
70
174
|
*/
|
|
71
|
-
register(args:
|
|
175
|
+
register(args: RegisterArgs): SessionEntry {
|
|
72
176
|
if (this.sessions.size >= this.deps.maxSessions && !this.evictOldestTerminal()) {
|
|
73
177
|
throw new SessionLimitError(
|
|
74
178
|
`runner is at its configured max of ${this.deps.maxSessions} concurrent sessions`,
|
|
@@ -77,32 +181,74 @@ export class SessionRegistry {
|
|
|
77
181
|
const entry: SessionEntry = {
|
|
78
182
|
sessionId: args.sessionId,
|
|
79
183
|
createdAt: new Date(),
|
|
184
|
+
mode: args.mode ?? "run",
|
|
80
185
|
buffer: new EventRingBuffer(this.deps.replayBufferBytes),
|
|
81
|
-
byteBuffer: new ByteRingBuffer(this.deps.replayBufferBytes),
|
|
82
186
|
emitter: new EventEmitter(),
|
|
83
|
-
|
|
187
|
+
apps: new Map(),
|
|
84
188
|
session: null,
|
|
85
189
|
status: { kind: "starting" },
|
|
86
190
|
exitedAt: null,
|
|
87
191
|
userStopped: false,
|
|
88
192
|
evictionTimer: null,
|
|
193
|
+
agent: args.agent,
|
|
194
|
+
checkpoint: null,
|
|
195
|
+
attribution: null,
|
|
196
|
+
launch: null,
|
|
197
|
+
subscribers: 0,
|
|
198
|
+
idleSince: Date.now(),
|
|
199
|
+
reloads: [],
|
|
200
|
+
checkpointedAt: null,
|
|
89
201
|
};
|
|
90
202
|
// Many transient SSE / WS subscribers per session is normal — bump the
|
|
91
203
|
// default 10-listener warning to a high cap so the alarm still fires
|
|
92
204
|
// for a real listener leak. 256 is well above the realistic concurrent-
|
|
93
205
|
// tab count and well below "obviously a bug".
|
|
94
206
|
entry.emitter.setMaxListeners(256);
|
|
95
|
-
|
|
207
|
+
for (const app of args.apps ?? [{ name: DEFAULT_APP_NAME }]) {
|
|
208
|
+
this.addApp(entry, app);
|
|
209
|
+
}
|
|
96
210
|
this.sessions.set(args.sessionId, entry);
|
|
97
211
|
return entry;
|
|
98
212
|
}
|
|
99
213
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
214
|
+
/** Add an application channel to an existing entry. Used at register time and
|
|
215
|
+
* when `PUT /v1/sessions/:id/apps` changes the running set. */
|
|
216
|
+
addApp(
|
|
217
|
+
entry: SessionEntry,
|
|
218
|
+
app: { name: string; io?: IoMode; ports?: PortMapping[] },
|
|
219
|
+
): AppChannel {
|
|
220
|
+
const channel: AppChannel = {
|
|
221
|
+
name: app.name,
|
|
222
|
+
io: app.io ?? "tty",
|
|
223
|
+
byteBuffer: new ByteRingBuffer(this.deps.replayBufferBytes),
|
|
224
|
+
byteEmitter: new EventEmitter(),
|
|
225
|
+
generation: 0,
|
|
226
|
+
startedAt: null,
|
|
227
|
+
ports: app.ports ?? [],
|
|
228
|
+
};
|
|
229
|
+
channel.byteEmitter.setMaxListeners(256);
|
|
230
|
+
entry.apps.set(app.name, channel);
|
|
231
|
+
return channel;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** The single app of a single-app session — what `/io` and `reload` fall back
|
|
235
|
+
* to when no `app` is given. Undefined when the session runs several, where
|
|
236
|
+
* there is no defensible default among many terminals. */
|
|
237
|
+
soleApp(entry: SessionEntry): AppChannel | undefined {
|
|
238
|
+
return entry.apps.size === 1 ? entry.apps.values().next().value : undefined;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
pushBytes(
|
|
242
|
+
sessionId: string,
|
|
243
|
+
appName: string,
|
|
244
|
+
bytes: Buffer,
|
|
245
|
+
stream: ByteStreamTag = "tty",
|
|
246
|
+
): BufferedBytes | undefined {
|
|
247
|
+
const channel = this.sessions.get(sessionId)?.apps.get(appName);
|
|
248
|
+
if (!channel) return undefined;
|
|
103
249
|
if (bytes.byteLength <= MAX_PUSH_CHUNK) {
|
|
104
|
-
const buffered =
|
|
105
|
-
|
|
250
|
+
const buffered = channel.byteBuffer.push(bytes, stream);
|
|
251
|
+
channel.byteEmitter.emit(BYTES_EMITTED, buffered);
|
|
106
252
|
return buffered;
|
|
107
253
|
}
|
|
108
254
|
// Split the chunk into MAX_PUSH_CHUNK-sized slices, each getting its
|
|
@@ -112,17 +258,25 @@ export class SessionRegistry {
|
|
|
112
258
|
const slice = bytes.subarray(off, Math.min(off + MAX_PUSH_CHUNK, bytes.byteLength));
|
|
113
259
|
// subarray shares memory with the parent buffer; copy so the ring's
|
|
114
260
|
// entry doesn't pin the original allocation past eviction.
|
|
115
|
-
last =
|
|
116
|
-
|
|
261
|
+
last = channel.byteBuffer.push(Buffer.from(slice), stream);
|
|
262
|
+
channel.byteEmitter.emit(BYTES_EMITTED, last);
|
|
117
263
|
}
|
|
118
264
|
return last;
|
|
119
265
|
}
|
|
120
266
|
|
|
121
|
-
subscribeBytes(
|
|
267
|
+
subscribeBytes(
|
|
268
|
+
sessionId: string,
|
|
269
|
+
appName: string,
|
|
270
|
+
listener: (b: BufferedBytes) => void,
|
|
271
|
+
): () => void {
|
|
122
272
|
const entry = this.sessions.get(sessionId);
|
|
123
273
|
if (!entry) throw new SessionEvictedError(`session '${sessionId}' not in registry`);
|
|
124
|
-
entry.
|
|
125
|
-
|
|
274
|
+
const channel = entry.apps.get(appName);
|
|
275
|
+
if (!channel) {
|
|
276
|
+
throw new UnknownAppError(`session '${sessionId}' runs no app named '${appName}'`);
|
|
277
|
+
}
|
|
278
|
+
channel.byteEmitter.on(BYTES_EMITTED, listener);
|
|
279
|
+
return () => channel.byteEmitter.off(BYTES_EMITTED, listener);
|
|
126
280
|
}
|
|
127
281
|
|
|
128
282
|
emit(sessionId: string, event: RunEvent): BufferedEvent | undefined {
|
|
@@ -133,13 +287,71 @@ export class SessionRegistry {
|
|
|
133
287
|
entry.status = event.status;
|
|
134
288
|
if (isTerminal(event.status)) {
|
|
135
289
|
entry.exitedAt = new Date();
|
|
136
|
-
this.scheduleEviction(entry);
|
|
290
|
+
this.scheduleEviction(entry, this.deps.exitTtlMs);
|
|
291
|
+
} else if (event.status.kind === "suspended") {
|
|
292
|
+
// Not terminal — the record and its checkpoint outlive the pod — but it
|
|
293
|
+
// still needs its own ceiling, or suspended records accumulate forever.
|
|
294
|
+
this.scheduleEviction(entry, this.deps.suspendedTtlMs);
|
|
295
|
+
} else {
|
|
296
|
+
this.cancelEviction(entry);
|
|
137
297
|
}
|
|
138
298
|
}
|
|
139
299
|
entry.emitter.emit(EVENT_EMITTED, buffered);
|
|
140
300
|
return buffered;
|
|
141
301
|
}
|
|
142
302
|
|
|
303
|
+
/** Begin a generation for one app and emit its `run.started`. Returns the new
|
|
304
|
+
* generation number, or undefined when the app is unknown. */
|
|
305
|
+
startGeneration(
|
|
306
|
+
sessionId: string,
|
|
307
|
+
appName: string,
|
|
308
|
+
trigger: "initial" | "watch" | "manual" | "resume",
|
|
309
|
+
): number | undefined {
|
|
310
|
+
const channel = this.sessions.get(sessionId)?.apps.get(appName);
|
|
311
|
+
if (!channel) return undefined;
|
|
312
|
+
channel.generation += 1;
|
|
313
|
+
channel.startedAt = Date.now();
|
|
314
|
+
this.emit(sessionId, {
|
|
315
|
+
type: "run",
|
|
316
|
+
app: appName,
|
|
317
|
+
generation: channel.generation,
|
|
318
|
+
phase: "started",
|
|
319
|
+
trigger,
|
|
320
|
+
});
|
|
321
|
+
return channel.generation;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/** Close the current generation of one app. A completion leaves the SESSION
|
|
325
|
+
* status untouched — that is the whole point of the split. */
|
|
326
|
+
finishGeneration(
|
|
327
|
+
sessionId: string,
|
|
328
|
+
appName: string,
|
|
329
|
+
outcome: { phase: "completed"; code: number } | { phase: "failed"; reason: string },
|
|
330
|
+
): void {
|
|
331
|
+
const channel = this.sessions.get(sessionId)?.apps.get(appName);
|
|
332
|
+
if (!channel || channel.generation === 0) return;
|
|
333
|
+
const durationMs = channel.startedAt === null ? undefined : Date.now() - channel.startedAt;
|
|
334
|
+
channel.startedAt = null;
|
|
335
|
+
if (outcome.phase === "completed") {
|
|
336
|
+
this.emit(sessionId, {
|
|
337
|
+
type: "run",
|
|
338
|
+
app: appName,
|
|
339
|
+
generation: channel.generation,
|
|
340
|
+
phase: "completed",
|
|
341
|
+
code: outcome.code,
|
|
342
|
+
durationMs,
|
|
343
|
+
});
|
|
344
|
+
} else {
|
|
345
|
+
this.emit(sessionId, {
|
|
346
|
+
type: "run",
|
|
347
|
+
app: appName,
|
|
348
|
+
generation: channel.generation,
|
|
349
|
+
phase: "failed",
|
|
350
|
+
reason: outcome.reason,
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
143
355
|
subscribe(sessionId: string, listener: (e: BufferedEvent) => void): () => void {
|
|
144
356
|
const entry = this.sessions.get(sessionId);
|
|
145
357
|
if (!entry) throw new SessionEvictedError(`session '${sessionId}' not in registry`);
|
|
@@ -147,25 +359,64 @@ export class SessionRegistry {
|
|
|
147
359
|
return () => entry.emitter.off(EVENT_EMITTED, listener);
|
|
148
360
|
}
|
|
149
361
|
|
|
150
|
-
/**
|
|
151
|
-
*
|
|
152
|
-
*
|
|
153
|
-
|
|
362
|
+
/** Count a live client. Idleness — no SSE/WS subscriber for the configured
|
|
363
|
+
* window — is what suspends a watch session, and aggressive reaping is what
|
|
364
|
+
* makes per-visitor watch sessions affordable; they only work as a pair. */
|
|
365
|
+
addSubscriber(sessionId: string): () => void {
|
|
366
|
+
const entry = this.sessions.get(sessionId);
|
|
367
|
+
if (!entry) return () => {};
|
|
368
|
+
entry.subscribers += 1;
|
|
369
|
+
entry.idleSince = null;
|
|
370
|
+
let released = false;
|
|
371
|
+
return () => {
|
|
372
|
+
if (released) return;
|
|
373
|
+
released = true;
|
|
374
|
+
entry.subscribers = Math.max(0, entry.subscribers - 1);
|
|
375
|
+
if (entry.subscribers === 0) entry.idleSince = Date.now();
|
|
376
|
+
};
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* Free a slot at capacity by removing the oldest RECLAIMABLE session: a
|
|
381
|
+
* terminated one (history kept for re-attach, which yields to a new run) or a
|
|
382
|
+
* SUSPENDED one.
|
|
383
|
+
*
|
|
384
|
+
* A suspended session is reclaimable because losing it costs exactly what the
|
|
385
|
+
* design already says a runner restart costs — a checkpoint the editor
|
|
386
|
+
* re-seeds from its own copy. Excluding it would let a handful of visitors who
|
|
387
|
+
* each left after five minutes hold every slot for the whole suspended TTL,
|
|
388
|
+
* which with the shipped defaults is a day.
|
|
389
|
+
*
|
|
390
|
+
* Ordered by when the session stopped being live, so the least recently
|
|
391
|
+
* abandoned goes first. Returns false when every session is still live.
|
|
392
|
+
*/
|
|
154
393
|
private evictOldestTerminal(): boolean {
|
|
155
394
|
let oldest: SessionEntry | undefined;
|
|
395
|
+
let oldestAt = Number.POSITIVE_INFINITY;
|
|
156
396
|
for (const entry of this.sessions.values()) {
|
|
157
|
-
|
|
158
|
-
if (
|
|
397
|
+
const since = reclaimableSince(entry);
|
|
398
|
+
if (since === null || since >= oldestAt) continue;
|
|
399
|
+
oldest = entry;
|
|
400
|
+
oldestAt = since;
|
|
159
401
|
}
|
|
160
402
|
if (!oldest) return false;
|
|
161
403
|
return this.remove(oldest.sessionId);
|
|
162
404
|
}
|
|
163
405
|
|
|
164
|
-
private
|
|
165
|
-
if (entry.evictionTimer) return;
|
|
406
|
+
private cancelEviction(entry: SessionEntry): void {
|
|
407
|
+
if (!entry.evictionTimer) return;
|
|
408
|
+
clearTimeout(entry.evictionTimer);
|
|
409
|
+
entry.evictionTimer = null;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
private scheduleEviction(entry: SessionEntry, ttlMs: number | undefined): void {
|
|
413
|
+
if (ttlMs === undefined) return;
|
|
414
|
+
// A resume re-arms from `running`, so an already-armed timer is replaced
|
|
415
|
+
// rather than kept — the surviving one would evict a live session.
|
|
416
|
+
this.cancelEviction(entry);
|
|
166
417
|
entry.evictionTimer = setTimeout(() => {
|
|
167
418
|
this.sessions.delete(entry.sessionId);
|
|
168
|
-
},
|
|
419
|
+
}, ttlMs);
|
|
169
420
|
// Allow process exit even if evictions are pending — they are pure state,
|
|
170
421
|
// not work.
|
|
171
422
|
entry.evictionTimer.unref?.();
|
|
@@ -183,3 +434,12 @@ export class SessionRegistry {
|
|
|
183
434
|
return true;
|
|
184
435
|
}
|
|
185
436
|
}
|
|
437
|
+
|
|
438
|
+
/** When a session stopped being live, or null while it still is. A terminated
|
|
439
|
+
* session is reclaimable from its exit; a suspended one from when it went idle
|
|
440
|
+
* (it has no pod and its workspace is a checkpoint the editor can re-seed). */
|
|
441
|
+
function reclaimableSince(entry: SessionEntry): number | null {
|
|
442
|
+
if (entry.exitedAt !== null) return entry.exitedAt.getTime();
|
|
443
|
+
if (entry.status.kind === "suspended") return entry.idleSince ?? 0;
|
|
444
|
+
return null;
|
|
445
|
+
}
|
|
@@ -7,8 +7,8 @@ import { normalizeBundlePath, validateSessionId, BundlePathError } from "./bundl
|
|
|
7
7
|
describe("EventRingBuffer", () => {
|
|
8
8
|
it("assigns monotonic ids starting at 1 and replays after an id", () => {
|
|
9
9
|
const buf = new EventRingBuffer(1_000_000);
|
|
10
|
-
buf.push({ type: "
|
|
11
|
-
buf.push({ type: "
|
|
10
|
+
buf.push({ type: "progress", phase: "boot", message: "a" });
|
|
11
|
+
buf.push({ type: "progress", phase: "boot", message: "b" });
|
|
12
12
|
const { entries, hasGap } = buf.replay(1);
|
|
13
13
|
expect(entries.map((e) => e.id)).toEqual([2]);
|
|
14
14
|
expect(hasGap).toBe(false);
|
|
@@ -16,7 +16,8 @@ describe("EventRingBuffer", () => {
|
|
|
16
16
|
|
|
17
17
|
it("evicts oldest entries past the byte cap but always retains the last", () => {
|
|
18
18
|
const buf = new EventRingBuffer(50);
|
|
19
|
-
for (let i = 0; i < 20; i++)
|
|
19
|
+
for (let i = 0; i < 20; i++)
|
|
20
|
+
buf.push({ type: "progress", phase: "boot", message: "x".repeat(20) });
|
|
20
21
|
expect(buf.size).toBeGreaterThanOrEqual(1);
|
|
21
22
|
expect(buf.bytes).toBeLessThanOrEqual(50 + 40);
|
|
22
23
|
const { hasGap } = buf.replay(0);
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { SessionRegistry, type SessionEntry } from "./registry.js";
|
|
4
|
+
import { WatchSupervisor } from "./watch-supervisor.js";
|
|
5
|
+
|
|
6
|
+
function makeRegistry() {
|
|
7
|
+
return new SessionRegistry({
|
|
8
|
+
maxSessions: 4,
|
|
9
|
+
exitTtlMs: 60_000,
|
|
10
|
+
replayBufferBytes: 100_000,
|
|
11
|
+
suspendedTtlMs: 3_600_000,
|
|
12
|
+
});
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
describe("suspended is not terminal", () => {
|
|
16
|
+
it("keeps the session and schedules its own eviction, not the exit one", () => {
|
|
17
|
+
const registry = makeRegistry();
|
|
18
|
+
const entry = registry.register({ sessionId: "s1", mode: "watch" });
|
|
19
|
+
registry.emit("s1", { type: "status", status: { kind: "suspended" } });
|
|
20
|
+
|
|
21
|
+
expect(registry.has("s1")).toBe(true);
|
|
22
|
+
// `exitedAt` is what marks a session terminal for capacity eviction — a
|
|
23
|
+
// suspended session must not be a candidate.
|
|
24
|
+
expect(entry.exitedAt).toBeNull();
|
|
25
|
+
expect(entry.evictionTimer).not.toBeNull();
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("cancels the suspended eviction when the session resumes", () => {
|
|
29
|
+
const registry = makeRegistry();
|
|
30
|
+
const entry = registry.register({ sessionId: "s1", mode: "watch" });
|
|
31
|
+
registry.emit("s1", { type: "status", status: { kind: "suspended" } });
|
|
32
|
+
registry.emit("s1", { type: "status", status: { kind: "running" } });
|
|
33
|
+
|
|
34
|
+
expect(entry.evictionTimer).toBeNull();
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
describe("a suspended session does not hold a slot", () => {
|
|
39
|
+
it("is evicted to make room rather than blocking a new session", () => {
|
|
40
|
+
// Losing it costs exactly what a runner restart already costs — a checkpoint
|
|
41
|
+
// the editor re-seeds. Excluding it would let a handful of visitors who each
|
|
42
|
+
// left after five minutes hold every slot for the whole suspended TTL.
|
|
43
|
+
const registry = new SessionRegistry({
|
|
44
|
+
maxSessions: 1,
|
|
45
|
+
exitTtlMs: 60_000,
|
|
46
|
+
replayBufferBytes: 100_000,
|
|
47
|
+
suspendedTtlMs: 86_400_000,
|
|
48
|
+
});
|
|
49
|
+
registry.register({ sessionId: "old", mode: "watch" });
|
|
50
|
+
registry.emit("old", { type: "status", status: { kind: "suspended" } });
|
|
51
|
+
|
|
52
|
+
expect(() => registry.register({ sessionId: "new", mode: "watch" })).not.toThrow();
|
|
53
|
+
expect(registry.has("old")).toBe(false);
|
|
54
|
+
expect(registry.has("new")).toBe(true);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("never evicts a live session to make room", () => {
|
|
58
|
+
const registry = new SessionRegistry({
|
|
59
|
+
maxSessions: 1,
|
|
60
|
+
exitTtlMs: 60_000,
|
|
61
|
+
replayBufferBytes: 100_000,
|
|
62
|
+
});
|
|
63
|
+
registry.register({ sessionId: "live", mode: "watch" });
|
|
64
|
+
registry.emit("live", { type: "status", status: { kind: "running" } });
|
|
65
|
+
|
|
66
|
+
expect(() => registry.register({ sessionId: "new", mode: "watch" })).toThrow();
|
|
67
|
+
expect(registry.has("live")).toBe(true);
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe("byte channels are per app", () => {
|
|
72
|
+
it("routes a push to the named app and drops one for an unknown name", () => {
|
|
73
|
+
const registry = makeRegistry();
|
|
74
|
+
const entry = registry.register({
|
|
75
|
+
sessionId: "s1",
|
|
76
|
+
mode: "watch",
|
|
77
|
+
apps: [{ name: "web" }, { name: "worker" }],
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
registry.pushBytes("s1", "web", Buffer.from("hello"));
|
|
81
|
+
expect(entry.apps.get("web")!.byteBuffer.size).toBe(1);
|
|
82
|
+
expect(entry.apps.get("worker")!.byteBuffer.size).toBe(0);
|
|
83
|
+
|
|
84
|
+
expect(registry.pushBytes("s1", "nope", Buffer.from("x"))).toBeUndefined();
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("carries the stream tag through the buffer", () => {
|
|
88
|
+
const registry = makeRegistry();
|
|
89
|
+
registry.register({ sessionId: "s1", apps: [{ name: "app", io: "streams" }] });
|
|
90
|
+
registry.pushBytes("s1", "app", Buffer.from("out"), "stdout");
|
|
91
|
+
registry.pushBytes("s1", "app", Buffer.from("err"), "stderr");
|
|
92
|
+
|
|
93
|
+
const entries = registry.get("s1")!.apps.get("app")!.byteBuffer.replay(0).entries;
|
|
94
|
+
expect(entries.map((e) => e.stream)).toEqual(["stdout", "stderr"]);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("has a sole app only when the session runs exactly one", () => {
|
|
98
|
+
const registry = makeRegistry();
|
|
99
|
+
const one = registry.register({ sessionId: "s1" });
|
|
100
|
+
const two = registry.register({
|
|
101
|
+
sessionId: "s2",
|
|
102
|
+
apps: [{ name: "web" }, { name: "worker" }],
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
expect(registry.soleApp(one)?.name).toBe("app");
|
|
106
|
+
expect(registry.soleApp(two)).toBeUndefined();
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
describe("WatchSupervisor", () => {
|
|
111
|
+
function watchEntry(registry: SessionRegistry, snapshot = vi.fn(async () => [])) {
|
|
112
|
+
const entry = registry.register({ sessionId: "s1", mode: "watch" });
|
|
113
|
+
const suspend = vi.fn(async () => {});
|
|
114
|
+
entry.session = {
|
|
115
|
+
writeStdin: () => {},
|
|
116
|
+
resize: () => {},
|
|
117
|
+
done: Promise.resolve(),
|
|
118
|
+
stop: async () => {},
|
|
119
|
+
suspend,
|
|
120
|
+
workspace: {
|
|
121
|
+
tree: async () => ({ files: [] }),
|
|
122
|
+
readFile: async () => ({ content: "", size: 0 }),
|
|
123
|
+
apply: async () => ({ written: 0, deleted: 0 }),
|
|
124
|
+
snapshot,
|
|
125
|
+
},
|
|
126
|
+
} as unknown as SessionEntry["session"];
|
|
127
|
+
registry.emit("s1", { type: "status", status: { kind: "running" } });
|
|
128
|
+
return { entry, suspend, snapshot };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
it("checkpoints on the timer while a client is attached", async () => {
|
|
132
|
+
const registry = makeRegistry();
|
|
133
|
+
const { entry, suspend, snapshot } = watchEntry(registry);
|
|
134
|
+
registry.addSubscriber("s1");
|
|
135
|
+
const supervisor = new WatchSupervisor({ registry, idleMs: 0, checkpointMs: 0 });
|
|
136
|
+
|
|
137
|
+
await supervisor.tick();
|
|
138
|
+
await vi.waitFor(() => expect(snapshot).toHaveBeenCalled());
|
|
139
|
+
|
|
140
|
+
expect(suspend).not.toHaveBeenCalled();
|
|
141
|
+
expect(entry.checkpoint).not.toBeNull();
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("snapshots before suspending — the volume dies with the pod", async () => {
|
|
145
|
+
const registry = makeRegistry();
|
|
146
|
+
const order: string[] = [];
|
|
147
|
+
const snapshot = vi.fn(async () => {
|
|
148
|
+
order.push("snapshot");
|
|
149
|
+
return [];
|
|
150
|
+
});
|
|
151
|
+
const { entry, suspend } = watchEntry(registry, snapshot);
|
|
152
|
+
suspend.mockImplementation(async () => {
|
|
153
|
+
order.push("suspend");
|
|
154
|
+
});
|
|
155
|
+
// No subscriber was ever added, so the entry is idle from registration.
|
|
156
|
+
const supervisor = new WatchSupervisor({ registry, idleMs: 0, checkpointMs: 60_000 });
|
|
157
|
+
|
|
158
|
+
await supervisor.tick();
|
|
159
|
+
await vi.waitFor(() => expect(entry.status.kind).toBe("suspended"));
|
|
160
|
+
|
|
161
|
+
expect(order).toEqual(["snapshot", "suspend"]);
|
|
162
|
+
expect(entry.session).toBeNull();
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("does not suspend while a client is attached", async () => {
|
|
166
|
+
const registry = makeRegistry();
|
|
167
|
+
const { suspend } = watchEntry(registry);
|
|
168
|
+
registry.addSubscriber("s1");
|
|
169
|
+
const supervisor = new WatchSupervisor({ registry, idleMs: 0, checkpointMs: 60_000 });
|
|
170
|
+
|
|
171
|
+
await supervisor.tick();
|
|
172
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
173
|
+
|
|
174
|
+
expect(suspend).not.toHaveBeenCalled();
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it("becomes idle again once the last subscriber detaches", async () => {
|
|
178
|
+
const registry = makeRegistry();
|
|
179
|
+
const { entry, suspend } = watchEntry(registry);
|
|
180
|
+
const release = registry.addSubscriber("s1");
|
|
181
|
+
expect(entry.idleSince).toBeNull();
|
|
182
|
+
release();
|
|
183
|
+
expect(entry.idleSince).not.toBeNull();
|
|
184
|
+
|
|
185
|
+
const supervisor = new WatchSupervisor({ registry, idleMs: 0, checkpointMs: 60_000 });
|
|
186
|
+
await supervisor.tick();
|
|
187
|
+
await vi.waitFor(() => expect(suspend).toHaveBeenCalled());
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("leaves run sessions alone", async () => {
|
|
191
|
+
const registry = makeRegistry();
|
|
192
|
+
const entry = registry.register({ sessionId: "s2", mode: "run" });
|
|
193
|
+
const suspend = vi.fn(async () => {});
|
|
194
|
+
entry.session = { suspend } as unknown as SessionEntry["session"];
|
|
195
|
+
registry.emit("s2", { type: "status", status: { kind: "running" } });
|
|
196
|
+
|
|
197
|
+
const supervisor = new WatchSupervisor({ registry, idleMs: 0, checkpointMs: 0 });
|
|
198
|
+
await supervisor.tick();
|
|
199
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
200
|
+
|
|
201
|
+
expect(suspend).not.toHaveBeenCalled();
|
|
202
|
+
});
|
|
203
|
+
});
|