@williamzujkowski/live-coding-music-mcp 4.0.0 → 4.1.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/README.md +202 -705
- package/dist/AudioAnalyzer.js +1294 -91
- package/dist/PatternStore.js +276 -23
- package/dist/StrudelController.js +388 -56
- package/dist/server/resources.js +27 -20
- package/dist/server/server.js +384 -76
- package/dist/server/tools/ai.js +124 -91
- package/dist/server/tools/analysis.js +168 -59
- package/dist/server/tools/capture.js +135 -24
- package/dist/server/tools/compose.js +26 -26
- package/dist/server/tools/diagnostics.js +20 -3
- package/dist/server/tools/editor.js +60 -6
- package/dist/server/tools/generate.js +38 -15
- package/dist/server/tools/history.js +30 -8
- package/dist/server/tools/playback.js +2 -1
- package/dist/server/tools/session.js +38 -6
- package/dist/server/tools/storage.js +179 -8
- package/dist/server/tools/transform.js +127 -43
- package/dist/server/tools/types.js +209 -14
- package/dist/services/AudioCaptureService.js +302 -33
- package/dist/services/AudioExportService.js +318 -0
- package/dist/services/BrowserOnlyFunctions.js +149 -0
- package/dist/services/EventDensityProbe.js +324 -0
- package/dist/services/GeminiService.js +221 -36
- package/dist/services/IsolatedEngineRunner.js +308 -0
- package/dist/services/IsolatedStrudelEngine.js +135 -0
- package/dist/services/LocalPatternEngine.js +16 -0
- package/dist/services/MIDIExportService.js +765 -71
- package/dist/services/MIDIImportService.js +474 -0
- package/dist/services/MusicTheory.js +85 -13
- package/dist/services/PatternGenerator.js +168 -85
- package/dist/services/PatternSandbox.js +359 -0
- package/dist/services/SessionManager.js +338 -65
- package/dist/services/StrudelEngine.js +218 -12
- package/dist/services/StrudelEngineHelpers.js +24 -8
- package/dist/services/StyleRegistry.js +204 -0
- package/dist/services/Transferable.js +114 -0
- package/dist/services/ai/AiTransport.js +56 -0
- package/dist/services/ai/AudioMeasurements.js +155 -0
- package/dist/services/ai/CliTransport.js +256 -0
- package/dist/services/engineChild.js +83 -0
- package/dist/services/engineChildPath.js +28 -0
- package/dist/utils/CategorisedError.js +59 -0
- package/dist/utils/ErrorRecovery.js +130 -110
- package/dist/utils/InputValidator.js +55 -7
- package/dist/utils/PatternValidator.js +136 -17
- package/dist/utils/SafePath.js +112 -0
- package/dist/utils/ServerConfig.js +248 -0
- package/dist/utils/TableLookup.js +41 -0
- package/dist/utils/Tempo.js +143 -0
- package/package.json +27 -16
- package/dist/services/__mocks__/StrudelEngine.js +0 -157
- package/dist/types/index.js +0 -1
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { chromium } from 'playwright';
|
|
2
|
-
import { StrudelController } from '../StrudelController.js';
|
|
2
|
+
import { StrudelController, waitForStrudelReady } from '../StrudelController.js';
|
|
3
|
+
import { DEFAULT_BLOCK_RESOURCES, DEFAULT_STRUDEL_URL, DEFAULT_VIEWPORT, } from '../utils/ServerConfig.js';
|
|
3
4
|
import { Logger } from '../utils/Logger.js';
|
|
5
|
+
import { BusinessError } from '../utils/CategorisedError.js';
|
|
4
6
|
/**
|
|
5
7
|
* Manages multiple concurrent Strudel browser sessions.
|
|
6
8
|
* Uses browser contexts for isolation - one browser instance with multiple contexts.
|
|
@@ -8,7 +10,42 @@ import { Logger } from '../utils/Logger.js';
|
|
|
8
10
|
*/
|
|
9
11
|
export class SessionManager {
|
|
10
12
|
browser = null;
|
|
13
|
+
/**
|
|
14
|
+
* In-flight browser launch, so concurrent creates share one.
|
|
15
|
+
*
|
|
16
|
+
* `if (!this.browser) { this.browser = await chromium.launch(...) }`
|
|
17
|
+
* has an await between the check and the assignment, and the MCP SDK
|
|
18
|
+
* does not serialize tool calls. Three concurrent
|
|
19
|
+
* session({action:'create'}) calls launched three Chromium processes
|
|
20
|
+
* and kept only the last assignment — the other two leaked with no
|
|
21
|
+
* handle left to close them (#317). Same class as #263, one level up:
|
|
22
|
+
* that one leaked contexts, this one leaks whole browsers.
|
|
23
|
+
*/
|
|
24
|
+
browserLaunch = null;
|
|
25
|
+
/** In-flight close, so a create waits for it rather than racing it. */
|
|
26
|
+
browserClose = null;
|
|
27
|
+
/**
|
|
28
|
+
* Handles that refused to close, kept so a later teardown can retry.
|
|
29
|
+
*
|
|
30
|
+
* `destroySession` and `closeBrowser` both logged a close failure and
|
|
31
|
+
* moved on, which dropped the last reference to the thing that failed
|
|
32
|
+
* — nothing could ever try again, and a context or browser that was
|
|
33
|
+
* only temporarily wedged stayed up for the life of the process
|
|
34
|
+
* (#423 item 9). Playwright exposes no process handle for a browser
|
|
35
|
+
* from `chromium.launch()` (`process()` is on BrowserServer and
|
|
36
|
+
* ElectronApplication, not Browser), so retrying the close is the
|
|
37
|
+
* strongest move available here.
|
|
38
|
+
*/
|
|
39
|
+
pendingCloses = new Map();
|
|
11
40
|
sessions = new Map();
|
|
41
|
+
/**
|
|
42
|
+
* Ids currently being created.
|
|
43
|
+
*
|
|
44
|
+
* Held from before the first await until the session is in `sessions`
|
|
45
|
+
* or the attempt has failed, so the duplicate and limit checks account
|
|
46
|
+
* for creations in flight rather than only completed ones (#263).
|
|
47
|
+
*/
|
|
48
|
+
reservedIds = new Set();
|
|
12
49
|
defaultSessionId = 'default';
|
|
13
50
|
logger;
|
|
14
51
|
isHeadless;
|
|
@@ -19,29 +56,81 @@ export class SessionManager {
|
|
|
19
56
|
/** Cleanup interval in ms (5 minutes) */
|
|
20
57
|
CLEANUP_INTERVAL = 5 * 60 * 1000;
|
|
21
58
|
cleanupTimer = null;
|
|
22
|
-
|
|
59
|
+
audioAnalysisConfig;
|
|
60
|
+
/** Strudel REPL URL; `strudel_url` in config.json (#227). */
|
|
61
|
+
strudelUrl;
|
|
62
|
+
/** Playwright viewport; null lets the page track the OS window (#514). */
|
|
63
|
+
viewport;
|
|
64
|
+
/** Resource types the per-session page route aborts (#514). */
|
|
65
|
+
blockResources;
|
|
66
|
+
/**
|
|
67
|
+
* Called after a session is removed, however it was removed.
|
|
68
|
+
*
|
|
69
|
+
* Lets the owner drop state it keys by session id without having to
|
|
70
|
+
* intercept every teardown path.
|
|
71
|
+
*/
|
|
72
|
+
onSessionDestroyed;
|
|
73
|
+
/**
|
|
74
|
+
* Whether a session has work the sweep must not interrupt.
|
|
75
|
+
*
|
|
76
|
+
* The manager cannot see the server's per-session state — an audio
|
|
77
|
+
* capture in progress, say — so the owner answers. Set alongside
|
|
78
|
+
* `onSessionDestroyed`; absent means "nothing extra to check" (#423).
|
|
79
|
+
*/
|
|
80
|
+
isSessionBusy;
|
|
81
|
+
constructor(headless = false, audioAnalysisConfig, strudelUrl = DEFAULT_STRUDEL_URL,
|
|
82
|
+
// Page-setup overrides from config.json `viewport` / `block_resources` (#514).
|
|
83
|
+
fidelity = {}) {
|
|
23
84
|
this.isHeadless = headless;
|
|
85
|
+
this.audioAnalysisConfig = audioAnalysisConfig;
|
|
86
|
+
this.strudelUrl = strudelUrl;
|
|
87
|
+
this.viewport = fidelity.viewport !== undefined ? fidelity.viewport : DEFAULT_VIEWPORT;
|
|
88
|
+
this.blockResources = fidelity.blockResources ?? DEFAULT_BLOCK_RESOURCES;
|
|
24
89
|
this.logger = new Logger();
|
|
25
90
|
}
|
|
26
91
|
/**
|
|
27
92
|
* Ensures the shared browser instance is running
|
|
28
93
|
*/
|
|
29
94
|
async ensureBrowser() {
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
});
|
|
42
|
-
// Start cleanup timer when browser is created
|
|
43
|
-
this.startCleanupTimer();
|
|
95
|
+
// A close in progress finishes before a launch starts, or the two
|
|
96
|
+
// race and the launch wins a browser that is then torn down (#423).
|
|
97
|
+
if (this.browserClose)
|
|
98
|
+
await this.browserClose;
|
|
99
|
+
// A browser that is still a live object but whose process is gone —
|
|
100
|
+
// a crashed Chromium — is not usable. Without this check every
|
|
101
|
+
// future create failed permanently, while `ensureInitialized` on the
|
|
102
|
+
// legacy path has had exactly this self-heal since #206.
|
|
103
|
+
if (this.browser && !this.browser.isConnected()) {
|
|
104
|
+
this.logger.warn('Shared browser is disconnected; relaunching.');
|
|
105
|
+
this.browser = null;
|
|
44
106
|
}
|
|
107
|
+
if (this.browser)
|
|
108
|
+
return this.browser;
|
|
109
|
+
// Single-flight: whoever gets here first starts the launch, everyone
|
|
110
|
+
// else awaits the same promise.
|
|
111
|
+
this.browserLaunch ??= this.launchBrowser();
|
|
112
|
+
try {
|
|
113
|
+
return await this.browserLaunch;
|
|
114
|
+
}
|
|
115
|
+
finally {
|
|
116
|
+
this.browserLaunch = null;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
/** Launches Chromium and starts the idle sweep. Call via ensureBrowser. */
|
|
120
|
+
async launchBrowser() {
|
|
121
|
+
this.browser = await chromium.launch({
|
|
122
|
+
headless: this.isHeadless,
|
|
123
|
+
args: [
|
|
124
|
+
'--use-fake-ui-for-media-stream',
|
|
125
|
+
'--disable-dev-shm-usage',
|
|
126
|
+
'--no-sandbox',
|
|
127
|
+
'--disable-setuid-sandbox',
|
|
128
|
+
'--disable-gpu',
|
|
129
|
+
'--disable-software-rasterizer'
|
|
130
|
+
],
|
|
131
|
+
});
|
|
132
|
+
// Start cleanup timer when browser is created
|
|
133
|
+
this.startCleanupTimer();
|
|
45
134
|
return this.browser;
|
|
46
135
|
}
|
|
47
136
|
/**
|
|
@@ -58,34 +147,75 @@ export class SessionManager {
|
|
|
58
147
|
if (!id || typeof id !== 'string') {
|
|
59
148
|
throw new Error('Session ID must be a non-empty string');
|
|
60
149
|
}
|
|
61
|
-
// Check if session already exists
|
|
62
|
-
if (this.sessions.has(id)) {
|
|
150
|
+
// Check if session already exists, or is mid-creation
|
|
151
|
+
if (this.sessions.has(id) || this.reservedIds.has(id)) {
|
|
63
152
|
throw new Error(`Session '${id}' already exists`);
|
|
64
153
|
}
|
|
65
|
-
// Check max sessions limit
|
|
66
|
-
if (this.sessions.size >= this.MAX_SESSIONS) {
|
|
67
|
-
throw new
|
|
154
|
+
// Check max sessions limit, counting creations in flight
|
|
155
|
+
if (this.sessions.size + this.reservedIds.size >= this.MAX_SESSIONS) {
|
|
156
|
+
throw new BusinessError(`Maximum session limit (${this.MAX_SESSIONS}) reached. Destroy an existing session first.`);
|
|
157
|
+
}
|
|
158
|
+
// Reserve the id synchronously, before the first await.
|
|
159
|
+
//
|
|
160
|
+
// The two checks above and the set below used to be ~2 seconds apart
|
|
161
|
+
// (ensureBrowser, newContext, newPage, goto, readiness wait), and the
|
|
162
|
+
// MCP SDK does not serialize tool calls. So two concurrent creates
|
|
163
|
+
// with the same id both passed the duplicate check and the second
|
|
164
|
+
// overwrote the first — orphaning a context nothing could reach. Four
|
|
165
|
+
// live sessions plus three concurrent creates all read size === 4 and
|
|
166
|
+
// produced seven on a five-session limit (#263).
|
|
167
|
+
this.reservedIds.add(id);
|
|
168
|
+
let context;
|
|
169
|
+
let controller;
|
|
170
|
+
try {
|
|
171
|
+
const browser = await this.ensureBrowser();
|
|
172
|
+
context = await browser.newContext({
|
|
173
|
+
permissions: ['microphone'],
|
|
174
|
+
// null is meaningful: Playwright tracks the real window size (#514).
|
|
175
|
+
viewport: this.viewport,
|
|
176
|
+
reducedMotion: 'reduce',
|
|
177
|
+
});
|
|
178
|
+
const page = await context.newPage();
|
|
179
|
+
controller = new StrudelController(this.isHeadless, this.audioAnalysisConfig, this.strudelUrl, undefined, { viewport: this.viewport, blockResources: this.blockResources });
|
|
180
|
+
await this.initializeControllerWithPage(controller, page);
|
|
181
|
+
const session = {
|
|
182
|
+
controller,
|
|
183
|
+
context,
|
|
184
|
+
page,
|
|
185
|
+
created: new Date(),
|
|
186
|
+
lastActivity: new Date(),
|
|
187
|
+
};
|
|
188
|
+
this.sessions.set(id, session);
|
|
189
|
+
}
|
|
190
|
+
catch (error) {
|
|
191
|
+
// Close what was allocated. Without this the context stayed alive
|
|
192
|
+
// and unreachable: it never entered `sessions`, so destroyAll, the
|
|
193
|
+
// idle sweep, and the MAX_SESSIONS count could none of them see it.
|
|
194
|
+
// Each failed create leaked a Chromium renderer while the limit
|
|
195
|
+
// still read 0/5, and an agent retrying a transient strudel.cc
|
|
196
|
+
// failure leaked one per attempt.
|
|
197
|
+
if (context !== undefined) {
|
|
198
|
+
await this.closeOrRetain(context, `context for '${id}'`);
|
|
199
|
+
}
|
|
200
|
+
// Released here rather than only in `finally`, because the check
|
|
201
|
+
// below has to see `reservedIds` without this attempt still in it.
|
|
202
|
+
// `finally` deletes again; Set.delete is idempotent.
|
|
203
|
+
this.reservedIds.delete(id);
|
|
204
|
+
// A failed create is the one path that can leave Chromium up with
|
|
205
|
+
// nothing to serve. `ensureBrowser` launches it and starts the
|
|
206
|
+
// 5-minute idle sweep, and only `destroySession` and `destroyAll`
|
|
207
|
+
// ever close it — neither of which runs for a session that never
|
|
208
|
+
// landed. So a server whose create failed, and which never made
|
|
209
|
+
// another, held a browser and a live interval with
|
|
210
|
+
// `sessions.size === 0` (#423 item 1).
|
|
211
|
+
if (this.sessions.size === 0 && this.reservedIds.size === 0) {
|
|
212
|
+
await this.closeBrowser();
|
|
213
|
+
}
|
|
214
|
+
throw error;
|
|
215
|
+
}
|
|
216
|
+
finally {
|
|
217
|
+
this.reservedIds.delete(id);
|
|
68
218
|
}
|
|
69
|
-
const browser = await this.ensureBrowser();
|
|
70
|
-
// Create isolated browser context
|
|
71
|
-
const context = await browser.newContext({
|
|
72
|
-
permissions: ['microphone'],
|
|
73
|
-
viewport: { width: 1280, height: 720 },
|
|
74
|
-
reducedMotion: 'reduce',
|
|
75
|
-
});
|
|
76
|
-
const page = await context.newPage();
|
|
77
|
-
// Create controller with injected page (using a factory pattern)
|
|
78
|
-
const controller = new StrudelController(this.isHeadless);
|
|
79
|
-
// Initialize the controller with the existing page
|
|
80
|
-
await this.initializeControllerWithPage(controller, page);
|
|
81
|
-
const session = {
|
|
82
|
-
controller,
|
|
83
|
-
context,
|
|
84
|
-
page,
|
|
85
|
-
created: new Date(),
|
|
86
|
-
lastActivity: new Date(),
|
|
87
|
-
};
|
|
88
|
-
this.sessions.set(id, session);
|
|
89
219
|
this.logger.info(`Session '${id}' created`, {
|
|
90
220
|
totalSessions: this.sessions.size,
|
|
91
221
|
});
|
|
@@ -99,7 +229,7 @@ export class SessionManager {
|
|
|
99
229
|
// Set up page routing for resource optimization
|
|
100
230
|
await page.route('**/*', (route) => {
|
|
101
231
|
const resourceType = route.request().resourceType();
|
|
102
|
-
if (
|
|
232
|
+
if (this.blockResources.includes(resourceType)) {
|
|
103
233
|
route.abort();
|
|
104
234
|
}
|
|
105
235
|
else {
|
|
@@ -107,20 +237,21 @@ export class SessionManager {
|
|
|
107
237
|
}
|
|
108
238
|
});
|
|
109
239
|
// Navigate to Strudel
|
|
110
|
-
await page.goto(
|
|
240
|
+
await page.goto(this.strudelUrl, {
|
|
111
241
|
waitUntil: 'domcontentloaded',
|
|
112
242
|
timeout: 15000,
|
|
113
243
|
});
|
|
114
|
-
//
|
|
115
|
-
|
|
116
|
-
//
|
|
117
|
-
await page
|
|
118
|
-
const editor = document.querySelector('.cm-content');
|
|
119
|
-
return editor && editor.__view;
|
|
120
|
-
}, { timeout: 5000 });
|
|
244
|
+
// Same readiness gate as StrudelController.initialize(). This used to
|
|
245
|
+
// wait on `editor.__view`, which strudel.cc does not expose, so every
|
|
246
|
+
// named session stalled and threw (#228).
|
|
247
|
+
await waitForStrudelReady(page);
|
|
121
248
|
// Inject page into controller via internal method
|
|
122
249
|
controller._page = page;
|
|
123
250
|
controller.browser = null; // Controller doesn't own the browser
|
|
251
|
+
// Console monitoring was previously only wired up on the single-session
|
|
252
|
+
// path, so diagnostics and validate_pattern_runtime reported zero errors
|
|
253
|
+
// for every named session (#228).
|
|
254
|
+
controller.setupConsoleMonitoring();
|
|
124
255
|
// Inject audio analyzer
|
|
125
256
|
await controller.analyzer.inject(page);
|
|
126
257
|
}
|
|
@@ -147,14 +278,35 @@ export class SessionManager {
|
|
|
147
278
|
if (!session) {
|
|
148
279
|
throw new Error(`Session '${id}' not found`);
|
|
149
280
|
}
|
|
281
|
+
// Out of the map BEFORE the await, not after.
|
|
282
|
+
//
|
|
283
|
+
// The entry used to survive `await context.close()`, so a concurrent
|
|
284
|
+
// `getSession(id)` handed out a controller on a closing page — and
|
|
285
|
+
// stamped `lastActivity` on it. The same window let two destroys of
|
|
286
|
+
// one id both pass the guard above, both close, both fire the
|
|
287
|
+
// callback below, and both reach `closeBrowser` (#423).
|
|
288
|
+
this.sessions.delete(id);
|
|
289
|
+
// Not rethrown: the entry is already gone, and a context that
|
|
290
|
+
// refuses to close is not a reason to keep serving it. The handle is
|
|
291
|
+
// retained rather than dropped, so the next teardown retries it
|
|
292
|
+
// (#423 item 9).
|
|
293
|
+
await this.closeOrRetain(session.context, `context for session '${id}'`);
|
|
294
|
+
// Tell the owner, whichever path got us here. The server keeps its
|
|
295
|
+
// own per-session maps (history bundles, capture services), and those
|
|
296
|
+
// used to be cleared only by the session({action:'destroy'}) tool
|
|
297
|
+
// handler — so idle eviction leaked them, and a session recreated
|
|
298
|
+
// under the same id inherited a dead recorder and a previous
|
|
299
|
+
// session's undo stack (#264).
|
|
300
|
+
//
|
|
301
|
+
// `destroyAll` fires it in its own loop rather than routing through
|
|
302
|
+
// here. This comment used to claim destroyAll was covered by this
|
|
303
|
+
// line, and it was not (#423).
|
|
150
304
|
try {
|
|
151
|
-
|
|
152
|
-
await session.context.close();
|
|
305
|
+
this.onSessionDestroyed?.(id);
|
|
153
306
|
}
|
|
154
307
|
catch (error) {
|
|
155
|
-
this.logger.warn(`
|
|
308
|
+
this.logger.warn(`Session destroy callback failed for '${id}': ${error.message}`);
|
|
156
309
|
}
|
|
157
|
-
this.sessions.delete(id);
|
|
158
310
|
// If this was the default session, reset to 'default'
|
|
159
311
|
if (this.defaultSessionId === id) {
|
|
160
312
|
this.defaultSessionId = 'default';
|
|
@@ -162,8 +314,12 @@ export class SessionManager {
|
|
|
162
314
|
this.logger.info(`Session '${id}' destroyed`, {
|
|
163
315
|
totalSessions: this.sessions.size,
|
|
164
316
|
});
|
|
165
|
-
// If no more sessions, close browser
|
|
166
|
-
|
|
317
|
+
// If no more sessions, close browser — unless a create is in
|
|
318
|
+
// flight. `reservedIds` holds ids between the limit check and the
|
|
319
|
+
// session landing (#263), which is exactly the window in which a
|
|
320
|
+
// concurrent destroy would close the browser the create is about to
|
|
321
|
+
// call newContext on (#317).
|
|
322
|
+
if (this.sessions.size === 0 && this.reservedIds.size === 0) {
|
|
167
323
|
await this.closeBrowser();
|
|
168
324
|
}
|
|
169
325
|
}
|
|
@@ -222,17 +378,31 @@ export class SessionManager {
|
|
|
222
378
|
async destroyAll() {
|
|
223
379
|
const sessionIds = this.listSessions();
|
|
224
380
|
for (const id of sessionIds) {
|
|
381
|
+
const session = this.sessions.get(id);
|
|
382
|
+
if (!session)
|
|
383
|
+
continue;
|
|
384
|
+
this.sessions.delete(id);
|
|
385
|
+
await this.closeOrRetain(session.context, `context for session '${id}'`);
|
|
386
|
+
// The same callback `destroySession` fires. This loop used to
|
|
387
|
+
// close contexts and call `sessions.clear()` without it, so the
|
|
388
|
+
// server's per-session history bundles and capture services were
|
|
389
|
+
// never dropped — while the comment on `destroySession` and the
|
|
390
|
+
// one at the callback's registration both said destroyAll was
|
|
391
|
+
// covered (#423). Only the shutdown path calls this, so nothing
|
|
392
|
+
// leaked in practice; the claim was the problem.
|
|
393
|
+
//
|
|
394
|
+
// It runs outside the close, because a `try` around both put the
|
|
395
|
+
// callback back on the same failure path the fix removed: a
|
|
396
|
+
// context that would not close skipped the teardown again.
|
|
225
397
|
try {
|
|
226
|
-
|
|
227
|
-
if (session) {
|
|
228
|
-
await session.context.close();
|
|
229
|
-
}
|
|
398
|
+
this.onSessionDestroyed?.(id);
|
|
230
399
|
}
|
|
231
400
|
catch (error) {
|
|
232
|
-
this.logger.warn(`
|
|
401
|
+
this.logger.warn(`Session destroy callback failed for '${id}': ${error.message}`);
|
|
233
402
|
}
|
|
234
403
|
}
|
|
235
404
|
this.sessions.clear();
|
|
405
|
+
this.defaultSessionId = 'default';
|
|
236
406
|
await this.closeBrowser();
|
|
237
407
|
this.logger.info('All sessions destroyed');
|
|
238
408
|
}
|
|
@@ -241,16 +411,89 @@ export class SessionManager {
|
|
|
241
411
|
*/
|
|
242
412
|
async closeBrowser() {
|
|
243
413
|
this.stopCleanupTimer();
|
|
244
|
-
|
|
414
|
+
const browser = this.browser;
|
|
415
|
+
if (!browser && this.pendingCloses.size === 0)
|
|
416
|
+
return;
|
|
417
|
+
// Cleared BEFORE the first await, not after.
|
|
418
|
+
//
|
|
419
|
+
// `ensureBrowser` returns `this.browser` when it is non-null, and it
|
|
420
|
+
// stayed non-null for the whole of `await browser.close()` — so a
|
|
421
|
+
// create landing in that window got a browser that was already going
|
|
422
|
+
// away and `newContext()` threw `Target page, context or browser has
|
|
423
|
+
// been closed`. `reservedIds` guards the window before closeBrowser
|
|
424
|
+
// is entered, not during it (#423).
|
|
425
|
+
//
|
|
426
|
+
// Nothing may await above this line: the retry below is an await,
|
|
427
|
+
// and running it first would reopen exactly that window.
|
|
428
|
+
this.browser = null;
|
|
429
|
+
// Held so a concurrent `ensureBrowser` waits for the close to finish
|
|
430
|
+
// rather than launching a second Chromium beside it.
|
|
431
|
+
this.browserClose = (async () => {
|
|
432
|
+
// Anything an earlier teardown could not close gets another
|
|
433
|
+
// attempt, before the browser it belongs to goes away (#423 item 9).
|
|
434
|
+
await this.retryPendingCloses();
|
|
435
|
+
// Not rethrown: the field is already cleared, and a browser that
|
|
436
|
+
// will not close is not a reason to keep handing it out. The
|
|
437
|
+
// handle is retained rather than dropped, so the next teardown
|
|
438
|
+
// retries it.
|
|
439
|
+
if (browser)
|
|
440
|
+
await this.closeOrRetain(browser, 'shared browser');
|
|
441
|
+
})();
|
|
442
|
+
try {
|
|
443
|
+
await this.browserClose;
|
|
444
|
+
}
|
|
445
|
+
finally {
|
|
446
|
+
this.browserClose = null;
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
/**
|
|
450
|
+
* Closes a handle, retaining it for a later retry if it refuses.
|
|
451
|
+
*
|
|
452
|
+
* Never throws: every caller is on a teardown path where the entry is
|
|
453
|
+
* already gone from the map, and a failed close must not stop the rest
|
|
454
|
+
* of the teardown.
|
|
455
|
+
*
|
|
456
|
+
* @param handle - Anything with an async `close()` — a context or the browser
|
|
457
|
+
* @param label - What to call it in the log
|
|
458
|
+
*/
|
|
459
|
+
async closeOrRetain(handle, label) {
|
|
460
|
+
try {
|
|
461
|
+
await handle.close();
|
|
462
|
+
this.pendingCloses.delete(handle);
|
|
463
|
+
}
|
|
464
|
+
catch (error) {
|
|
465
|
+
this.pendingCloses.set(handle, label);
|
|
466
|
+
this.logger.error(`Failed to close ${label}; retained for retry`, error);
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
/**
|
|
470
|
+
* Retries handles an earlier teardown could not close.
|
|
471
|
+
*
|
|
472
|
+
* One that still refuses stays in the map for the teardown after this
|
|
473
|
+
* one. The map holds at most one entry per handle and entries are
|
|
474
|
+
* dropped on success, so a repeatedly-wedged handle costs one
|
|
475
|
+
* reference, not a growing list.
|
|
476
|
+
*/
|
|
477
|
+
async retryPendingCloses() {
|
|
478
|
+
for (const [handle, label] of [...this.pendingCloses]) {
|
|
245
479
|
try {
|
|
246
|
-
await
|
|
480
|
+
await handle.close();
|
|
481
|
+
this.pendingCloses.delete(handle);
|
|
482
|
+
this.logger.info(`Closed ${label} on retry`);
|
|
247
483
|
}
|
|
248
484
|
catch (error) {
|
|
249
|
-
this.logger.warn(
|
|
485
|
+
this.logger.warn(`${label} still will not close: ${error.message}`);
|
|
250
486
|
}
|
|
251
|
-
this.browser = null;
|
|
252
487
|
}
|
|
253
488
|
}
|
|
489
|
+
/**
|
|
490
|
+
* Number of handles retained after a failed close. Test seam for #423
|
|
491
|
+
* item 9; nothing in src/ reads it.
|
|
492
|
+
* @returns Count of handles awaiting a retry
|
|
493
|
+
*/
|
|
494
|
+
getPendingCloseCount() {
|
|
495
|
+
return this.pendingCloses.size;
|
|
496
|
+
}
|
|
254
497
|
/**
|
|
255
498
|
* Starts the automatic cleanup timer
|
|
256
499
|
*/
|
|
@@ -285,6 +528,36 @@ export class SessionManager {
|
|
|
285
528
|
}
|
|
286
529
|
}
|
|
287
530
|
for (const id of sessionsToDestroy) {
|
|
531
|
+
// Rechecked, because the list is a snapshot and each destroy
|
|
532
|
+
// awaits. A session used while an earlier one was being torn down
|
|
533
|
+
// had its `lastActivity` refreshed and was killed anyway — mid-use
|
|
534
|
+
// (#423).
|
|
535
|
+
const session = this.sessions.get(id);
|
|
536
|
+
if (!session)
|
|
537
|
+
continue;
|
|
538
|
+
if (Date.now() - session.lastActivity.getTime() <= this.INACTIVITY_TIMEOUT) {
|
|
539
|
+
this.logger.info(`Session '${id}' became active during the sweep; keeping it`);
|
|
540
|
+
continue;
|
|
541
|
+
}
|
|
542
|
+
// "Idle" meant "no tool call recently", which is not the same as
|
|
543
|
+
// "doing nothing". `lastActivity` moves only in `getSession`, so a
|
|
544
|
+
// session left playing — or recording, since `audio_capture start`
|
|
545
|
+
// returns immediately and the capture runs on in the page for up
|
|
546
|
+
// to ten minutes — looked idle and was torn down underneath its
|
|
547
|
+
// own audio (#423).
|
|
548
|
+
//
|
|
549
|
+
// `getPlaybackState()` is a cached flag, not a page read (#456),
|
|
550
|
+
// so it can say "playing" for a session the user stopped by hand.
|
|
551
|
+
// That errs toward keeping a session too long, which is the safe
|
|
552
|
+
// direction for a destructive sweep.
|
|
553
|
+
if (session.controller.getPlaybackState()) {
|
|
554
|
+
this.logger.info(`Session '${id}' is still playing; keeping it`);
|
|
555
|
+
continue;
|
|
556
|
+
}
|
|
557
|
+
if (this.isSessionBusy?.(id) === true) {
|
|
558
|
+
this.logger.info(`Session '${id}' has work in flight; keeping it`);
|
|
559
|
+
continue;
|
|
560
|
+
}
|
|
288
561
|
this.logger.info(`Auto-destroying inactive session '${id}'`);
|
|
289
562
|
try {
|
|
290
563
|
await this.destroySession(id);
|