From e2ae03a0c6847eaf9cb6002608cfae283683182e Mon Sep 17 00:00:00 2001 From: "Torkild U. Resheim" Date: Fri, 28 Aug 2026 13:20:02 +0200 Subject: [PATCH 1/3] Fix SIDScore vibrato instrument loading --- example-workspace/Presentasjon/demo.sidscore | 187 ++++++++++++++++++ .../browser/sid-instrument-control-widget.tsx | 9 + .../browser/sidscore-runtime-contribution.ts | 1 + 3 files changed, 197 insertions(+) create mode 100644 example-workspace/Presentasjon/demo.sidscore diff --git a/example-workspace/Presentasjon/demo.sidscore b/example-workspace/Presentasjon/demo.sidscore new file mode 100644 index 0000000..67ed7f4 --- /dev/null +++ b/example-workspace/Presentasjon/demo.sidscore @@ -0,0 +1,187 @@ +TITLE "Moog to MOS - SID Demo" +AUTHOR "Torkild" +TEMPO 132 +TIME 4/4 +SYSTEM PAL + +; ------------------------------------------------------------ +; Modulation tables +; ------------------------------------------------------------ + +; Pulse-width modulation. +; Move around the 50% ($0800) pulse width. +TABLE pw DemoPWM { + $0300 @3 + $0500 @3 + $0800 @3 + $0B00 @3 + $0D00 @3 + $0B00 @3 + $0800 @3 + $0500 @3 + LOOP +} + +; Start dark and rapidly open the low-pass filter. +TABLE filter DemoSweep { + 120 @3 + 300 @3 + 600 @3 + 1000 @3 + 1500 @3 + 1900 HOLD +} + +; ------------------------------------------------------------ +; Instruments +; Keep ADSR identical so we mainly hear the waveform changes. +; ------------------------------------------------------------ + +INSTR triangle + WAVE=TRI + ADSR=2,4,12,4 + +INSTR saw + WAVE=SAW + ADSR=2,4,12,4 + +INSTR pulse + WAVE=PULSE + PW=$0800 + ADSR=2,4,12,4 + +INSTR pulseFilter + WAVE=PULSE + PW=$0800 + ADSR=2,4,12,4 + FILTER=LP + CUTOFF=900 + RES=8 + +INSTR pulseVibrato + WAVE=PULSE + PW=$0800 + ADSR=2,4,12,4 + FILTER=LP + CUTOFF=900 + RES=8 + VIBRATO=0,32,128,0 + +INSTR pulsePWM + WAVE=PULSE + PW=$0800 + ADSR=2,4,12,4 + FILTER=LP + CUTOFF=900 + RES=8 + PWSEQ=DemoPWM + +INSTR pulseSweep + WAVE=PULSE + PW=$0800 + ADSR=2,4,12,4 + FILTER=LP + CUTOFF=120 + RES=12 + FILTERSEQ=DemoSweep + +; ------------------------------------------------------------ +; Tune 1 - Triangle +; ------------------------------------------------------------ + +VOICE 1 triangle: + O4 L8 + C E G > C + < G4 E4 + D8 F A > C + < B4 G4 + +; ------------------------------------------------------------ +; Tune 2 - Saw +; ------------------------------------------------------------ + +TUNE 2 { + TITLE "Saw" + + VOICE 1 saw: + O4 L8 + C E G > C + < G4 E4 + D8 F A > C + < B4 G4 +} + +; ------------------------------------------------------------ +; Tune 3 - Pulse +; ------------------------------------------------------------ + +TUNE 3 { + TITLE "Pulse" + + VOICE 1 pulse: + O4 L8 + C E G > C + < G4 E4 + D8 F A > C + < B4 G4 +} + +; ------------------------------------------------------------ +; Tune 4 - Pulse + filter +; ------------------------------------------------------------ + +TUNE 4 { + TITLE "Pulse + Filter" + + VOICE 1 pulseFilter: + O4 L8 + C E G > C + < G4 E4 + D8 F A > C + < B4 G4 +} + +; ------------------------------------------------------------ +; Tune 5 - Vibrato +; ------------------------------------------------------------ + +TUNE 5 { + TITLE "Pulse + Filter + Vibrato" + + VOICE 1 pulseVibrato: + O4 L8 + C E G > C + < G4 E4 + D8 F A > C + < B4 G4 +} + +; ------------------------------------------------------------ +; Tune 6 - PWM +; ------------------------------------------------------------ + +TUNE 6 { + TITLE "Pulse + Filter + PWM" + + VOICE 1 pulsePWM: + O4 L8 + C E G > C + < G4 E4 + D8 F A > C + < B4 G4 +} + +; ------------------------------------------------------------ +; Tune 7 - Filter sweep +; ------------------------------------------------------------ + +TUNE 7 { + TITLE "Pulse + Filter Sweep" + + VOICE 1 pulseSweep: + O4 L8 + C E G > C + < G4 E4 + D8 F A > C + < B4 G4 +} diff --git a/packages/theia-extension/src/browser/sid-instrument-control-widget.tsx b/packages/theia-extension/src/browser/sid-instrument-control-widget.tsx index fd1d969..76f9f7e 100644 --- a/packages/theia-extension/src/browser/sid-instrument-control-widget.tsx +++ b/packages/theia-extension/src/browser/sid-instrument-control-widget.tsx @@ -559,6 +559,15 @@ export class SidInstrumentControlWidget extends ReactWidget { } } + beginScoreInstrumentLoad(): void { + this.clearInstrumentUpdateTimer(); + this.clearPendingInstrumentVoices(); + this.instrumentStates.clear(); + this.nonOverrideInstrumentStates.clear(); + this.instrumentSource = 'default'; + this.update(); + } + async initializeMidiDevices(): Promise { if (!shouldStartInitialMidiDeviceScan({ initialMidiScanStarted: this.initialMidiScanStarted, diff --git a/packages/theia-extension/src/browser/sidscore-runtime-contribution.ts b/packages/theia-extension/src/browser/sidscore-runtime-contribution.ts index 7b9883b..7d1ce5f 100644 --- a/packages/theia-extension/src/browser/sidscore-runtime-contribution.ts +++ b/packages/theia-extension/src/browser/sidscore-runtime-contribution.ts @@ -479,6 +479,7 @@ export class SidScoreRuntimeContribution this.scorePlaybackRequestId = requestId; this.scorePlaybackScoreId = undefined; const instrumentWidget = await this.openInstrumentWidgetForScorePlayback(); + instrumentWidget?.beginScoreInstrumentLoad(); instrumentWidget?.setScorePlaybackActive(true, false); await instrumentWidget?.prepareMidiForScorePlayback(); const result = await this.sidScoreRuntimeService.play({ From 4597b95986f1e33ca4368f9541bef874c7916105 Mon Sep 17 00:00:00 2001 From: "Torkild U. Resheim" Date: Fri, 28 Aug 2026 13:27:55 +0200 Subject: [PATCH 2/3] Fix embedded VICE shifted matrix key release --- .../commodore-machine-profile-widget.tsx | 27 ++++++- .../src/browser/vice-embedded-widget.tsx | 27 ++++++- .../src/browser/vice-keyboard-mapping.ts | 70 +++++++++++++++++ .../src/test/vice-keyboard-mapping.test.ts | 77 +++++++++++++++++++ 4 files changed, 194 insertions(+), 7 deletions(-) diff --git a/packages/theia-extension/src/browser/commodore-machine-profile-widget.tsx b/packages/theia-extension/src/browser/commodore-machine-profile-widget.tsx index 579f2ae..4ed1eda 100644 --- a/packages/theia-extension/src/browser/commodore-machine-profile-widget.tsx +++ b/packages/theia-extension/src/browser/commodore-machine-profile-widget.tsx @@ -49,7 +49,8 @@ import { } from './vice-canvas-scaling'; import { createViceEmbedKeyEvent, - isViceEmbedCommodoreFunctionKeyEvent + isViceEmbedCommodoreFunctionKeyEvent, + ViceEmbedKeyEventTracker } from './vice-keyboard-mapping'; import { DEFAULT_COMMODORE_EMULATOR_VICE_MENU_SHORTCUT, @@ -163,6 +164,7 @@ export class CommodoreMachineProfileWidget protected virtualShiftLatched = false; protected virtualCommodoreLatched = false; protected virtualControlLatched = false; + protected readonly keyEventTracker = new ViceEmbedKeyEventTracker(); protected readonly activeVirtualKeyboardKeys = new Map(); protected pressedVirtualMouseKey: PressedVirtualMouseKey | undefined; @@ -869,7 +871,9 @@ export class CommodoreMachineProfileWidget }; protected readonly handleWindowBlur = (): void => { + const releasedTrackedKeys = this.releaseTrackedKeyboardKeys(); if ( + releasedTrackedKeys || this.pressedVirtualMouseKey || this.hostShiftPressed || this.hostCommodorePressed || @@ -1427,6 +1431,7 @@ export class CommodoreMachineProfileWidget if (releaseMouseKey) { this.releasePressedVirtualMouseKey(); } + this.keyEventTracker.reset(); this.hostShiftPressed = false; this.hostCommodorePressed = false; this.hostControlPressed = false; @@ -1814,7 +1819,12 @@ export class CommodoreMachineProfileWidget } this.consumeEmulatorShortcutEvent(event); - this.sendKeyEventPayload(createViceEmbedKeyEvent(event, pressed)); + this.sendKeyEventPayload( + this.keyEventTracker.createKeyEvent( + this.keyboardEventForEmulator(event), + pressed + ) + ); this.trackVirtualKeyboardKey(event, pressed); return true; } @@ -1873,10 +1883,21 @@ export class CommodoreMachineProfileWidget } } const keyEvent: CommodoreViceEmbedKeyEvent = - createViceEmbedKeyEvent(this.keyboardEventForEmulator(event), pressed); + this.keyEventTracker.createKeyEvent( + this.keyboardEventForEmulator(event), + pressed + ); this.sendKeyEventPayload(keyEvent); } + protected releaseTrackedKeyboardKeys(): boolean { + const releases = this.keyEventTracker.releasePressedMatrixKeys(); + for (const keyEvent of releases) { + this.sendKeyEventPayload(keyEvent); + } + return releases.length > 0; + } + protected keyboardEventForEmulator( event: React.KeyboardEvent | KeyboardEvent ): React.KeyboardEvent | KeyboardEvent | NormalizedKeyboardEventLike { diff --git a/packages/theia-extension/src/browser/vice-embedded-widget.tsx b/packages/theia-extension/src/browser/vice-embedded-widget.tsx index 675e0b0..e1ae3e7 100644 --- a/packages/theia-extension/src/browser/vice-embedded-widget.tsx +++ b/packages/theia-extension/src/browser/vice-embedded-widget.tsx @@ -23,8 +23,8 @@ import { type ViceCanvasDisplaySize } from './vice-canvas-scaling'; import { - createViceEmbedKeyEvent, - isViceEmbedCommodoreFunctionKeyEvent + isViceEmbedCommodoreFunctionKeyEvent, + ViceEmbedKeyEventTracker } from './vice-keyboard-mapping'; export const VICE_EMBEDDED_WIDGET_ID = 'commodore-commander.vice-embedded'; @@ -52,6 +52,7 @@ export class ViceEmbeddedWidget protected lastOutput = ''; protected starting = false; protected hostCommodorePressed = false; + protected readonly keyEventTracker = new ViceEmbedKeyEventTracker(); @postConstruct() protected init(): void { @@ -68,12 +69,14 @@ export class ViceEmbeddedWidget ); document.addEventListener('keydown', this.handleDocumentKeyDown, true); document.addEventListener('keyup', this.handleDocumentKeyUp, true); + window.addEventListener('blur', this.handleWindowBlur); this.update(); } override dispose(): void { document.removeEventListener('keydown', this.handleDocumentKeyDown, true); document.removeEventListener('keyup', this.handleDocumentKeyUp, true); + window.removeEventListener('blur', this.handleWindowBlur); this.resizeObserver?.disconnect(); this.resizeObserver = undefined; this.viceEmbedService.setClient(undefined); @@ -99,6 +102,7 @@ export class ViceEmbeddedWidget this.statusMessage = event.message ?? event.state; this.starting = event.state === 'starting'; if (event.state === 'stopped' || event.state === 'error') { + this.keyEventTracker.reset(); this.frame = undefined; this.refreshCanvasDisplaySize(); } @@ -223,6 +227,7 @@ export class ViceEmbeddedWidget protected readonly stopVice = async (): Promise => { await this.viceEmbedService.stop(); + this.keyEventTracker.reset(); this.status = 'stopped'; this.statusMessage = 'Stopped'; this.frame = undefined; @@ -331,7 +336,12 @@ export class ViceEmbeddedWidget event.preventDefault(); event.stopPropagation(); event.stopImmediatePropagation(); - void this.viceEmbedService.sendKey(createViceEmbedKeyEvent(event, pressed)); + void this.viceEmbedService.sendKey( + this.keyEventTracker.createKeyEvent( + this.keyboardEventForEmulator(event), + pressed + ) + ); return true; } @@ -353,10 +363,19 @@ export class ViceEmbeddedWidget this.hostCommodorePressed = pressed; } const keyEvent: CommodoreViceEmbedKeyEvent = - createViceEmbedKeyEvent(this.keyboardEventForEmulator(event), pressed); + this.keyEventTracker.createKeyEvent( + this.keyboardEventForEmulator(event), + pressed + ); void this.viceEmbedService.sendKey(keyEvent); } + protected readonly handleWindowBlur = (): void => { + for (const keyEvent of this.keyEventTracker.releasePressedMatrixKeys()) { + void this.viceEmbedService.sendKey(keyEvent); + } + }; + protected keyboardEventForEmulator( event: React.KeyboardEvent | KeyboardEvent ): React.KeyboardEvent | KeyboardEvent | NormalizedKeyboardEventLike { diff --git a/packages/theia-extension/src/browser/vice-keyboard-mapping.ts b/packages/theia-extension/src/browser/vice-keyboard-mapping.ts index 6a7f605..19950a1 100644 --- a/packages/theia-extension/src/browser/vice-keyboard-mapping.ts +++ b/packages/theia-extension/src/browser/vice-keyboard-mapping.ts @@ -215,6 +215,47 @@ export function createViceEmbedKeyEvent( }; } +export class ViceEmbedKeyEventTracker { + protected readonly pressedMatrixKeys = new Map(); + + createKeyEvent( + event: ViceEmbedKeyboardEventLike, + pressed: boolean + ): CommodoreViceEmbedKeyEvent { + const keyEvent = createViceEmbedKeyEvent(event, pressed); + const identity = viceEmbedKeyboardEventIdentity(event); + + if (pressed) { + if (isMatrixKeyEvent(keyEvent)) { + this.pressedMatrixKeys.set(identity, keyEvent); + } else { + this.pressedMatrixKeys.delete(identity); + } + return keyEvent; + } + + const pressedKeyEvent = this.pressedMatrixKeys.get(identity); + this.pressedMatrixKeys.delete(identity); + if (pressedKeyEvent && !isSameMatrixKey(keyEvent, pressedKeyEvent)) { + return releaseTrackedMatrixKeyEvent(pressedKeyEvent); + } + return keyEvent; + } + + releasePressedMatrixKeys(): CommodoreViceEmbedKeyEvent[] { + const releases = Array.from( + this.pressedMatrixKeys.values(), + releaseTrackedMatrixKeyEvent + ); + this.pressedMatrixKeys.clear(); + return releases; + } + + reset(): void { + this.pressedMatrixKeys.clear(); + } +} + export function isViceEmbedCommodoreFunctionKeyEvent( event: Pick ): boolean { @@ -289,6 +330,35 @@ function isCommodoreFunctionKeyName(value: string): boolean { return /^[Ff][1-8]$/u.test(value); } +function viceEmbedKeyboardEventIdentity( + event: Pick +): string { + return event.code || `${event.keyCode}:${event.key}`; +} + +function isMatrixKeyEvent(event: CommodoreViceEmbedKeyEvent): boolean { + return event.matrixRow !== undefined && event.matrixCol !== undefined; +} + +function isSameMatrixKey( + event: CommodoreViceEmbedKeyEvent, + other: CommodoreViceEmbedKeyEvent +): boolean { + return event.matrixRow === other.matrixRow && + event.matrixCol === other.matrixCol && + Boolean(event.matrixShift) === Boolean(other.matrixShift); +} + +function releaseTrackedMatrixKeyEvent( + event: CommodoreViceEmbedKeyEvent +): CommodoreViceEmbedKeyEvent { + return { + ...event, + pressed: false, + repeat: false + }; +} + function charCode(value: string): number { return value.charCodeAt(0); } diff --git a/packages/theia-extension/src/test/vice-keyboard-mapping.test.ts b/packages/theia-extension/src/test/vice-keyboard-mapping.test.ts index 52e034a..427f38c 100644 --- a/packages/theia-extension/src/test/vice-keyboard-mapping.test.ts +++ b/packages/theia-extension/src/test/vice-keyboard-mapping.test.ts @@ -4,6 +4,7 @@ import { test } from 'node:test'; import { createViceEmbedKeyEvent, isViceEmbedCommodoreFunctionKeyEvent, + ViceEmbedKeyEventTracker, type ViceEmbedKeyboardEventLike } from '../browser/vice-keyboard-mapping'; @@ -559,6 +560,82 @@ test('VICE keyboard mapping sends C64 matrix fallback metadata for angle bracket assert.equal(greaterThan.sdlShift, false); }); +test('VICE keyboard event tracker releases the pressed angle-bracket matrix key after host Shift changes', () => { + const tracker = new ViceEmbedKeyEventTracker(); + + const down = tracker.createKeyEvent( + keyboardEvent({ + code: 'IntlBackslash', + key: '>', + keyCode: 62, + shiftKey: true + }), + true + ); + const up = tracker.createKeyEvent( + keyboardEvent({ + code: 'IntlBackslash', + key: '<', + keyCode: 60, + shiftKey: false + }), + false + ); + + assert.deepEqual(matrixKey(down), { row: 5, col: 4, shift: true }); + assert.deepEqual(matrixKey(up), { row: 5, col: 4, shift: true }); + assert.equal(up.sdlKeyCode, 46); + assert.equal(up.pressed, false); + assert.equal(up.repeat, false); +}); + +test('VICE keyboard event tracker releases shifted matrix keys when key-up loses the matrix mapping', () => { + const tracker = new ViceEmbedKeyEventTracker(); + + const down = tracker.createKeyEvent( + keyboardEvent({ + code: 'Digit2', + key: '"', + keyCode: 50, + shiftKey: true + }), + true + ); + const up = tracker.createKeyEvent( + keyboardEvent({ + code: 'Digit2', + key: '2', + keyCode: 50, + shiftKey: false + }), + false + ); + + assert.deepEqual(matrixKey(down), { row: 7, col: 3, shift: true }); + assert.deepEqual(matrixKey(up), { row: 7, col: 3, shift: true }); + assert.equal(up.sdlKeyCode, 50); + assert.equal(up.pressed, false); +}); + +test('VICE keyboard event tracker can release tracked matrix keys without a key-up event', () => { + const tracker = new ViceEmbedKeyEventTracker(); + + tracker.createKeyEvent( + keyboardEvent({ + code: 'IntlBackslash', + key: '<', + keyCode: 60 + }), + true + ); + + const releases = tracker.releasePressedMatrixKeys(); + assert.equal(releases.length, 1); + assert.deepEqual(matrixKey(releases[0]), { row: 5, col: 7, shift: true }); + assert.equal(releases[0].pressed, false); + assert.equal(tracker.releasePressedMatrixKeys().length, 0); +}); + function keyboardEvent( overrides: Partial ): ViceEmbedKeyboardEventLike { From 913783da4f55203b24c5a9e3624538172236c26a Mon Sep 17 00:00:00 2001 From: "Torkild U. Resheim" Date: Fri, 28 Aug 2026 16:45:11 +0200 Subject: [PATCH 3/3] Drop stale backend clients after RPC disconnect --- .../node/commodore-vice-embed-service-impl.ts | 40 +++++++++- .../node/kick-assembler-build-service-impl.ts | 17 +++++ .../src/node/rpc-client-lifecycle.ts | 20 +++++ .../src/node/sidscore-runtime-service-impl.ts | 28 ++++++- .../commodore-vice-embed-service-impl.test.ts | 49 +++++++++++++ .../sidscore-runtime-client-lifecycle.test.ts | 73 +++++++++++++++++++ 6 files changed, 222 insertions(+), 5 deletions(-) create mode 100644 packages/theia-extension/src/node/rpc-client-lifecycle.ts create mode 100644 packages/theia-extension/src/test/sidscore-runtime-client-lifecycle.test.ts diff --git a/packages/theia-extension/src/node/commodore-vice-embed-service-impl.ts b/packages/theia-extension/src/node/commodore-vice-embed-service-impl.ts index f702787..fdff79c 100644 --- a/packages/theia-extension/src/node/commodore-vice-embed-service-impl.ts +++ b/packages/theia-extension/src/node/commodore-vice-embed-service-impl.ts @@ -12,6 +12,7 @@ import { } from 'node:net'; import path from 'node:path'; +import type { Disposable } from '@theia/core/lib/common/disposable'; import { ILogger } from '@theia/core/lib/common/logger'; import { PreferenceService } from '@theia/core/lib/common/preferences'; import type { BackendApplicationContribution } from '@theia/core/lib/node/backend-application'; @@ -50,6 +51,7 @@ import { startsWithViceEmbedBinaryFrame, type CommodoreViceEmbedCommand } from './commodore-vice-embed-protocol'; +import { observeRpcClientClose } from './rpc-client-lifecycle'; const DEFAULT_VICE_EMULATOR = 'x64sc'; const EMBED_FLAG = '-cc-embed'; @@ -59,6 +61,7 @@ const EMBED_COMMAND_FD = 3; const MAX_UNFRAMED_STDOUT_BYTES = 32 * 1024 * 1024; const MAX_FRAME_TRANSPORT_BUFFER_BYTES = 32 * 1024 * 1024; const MIN_FRAME_SOCKET_BACKPRESSURE_BYTES = 256 * 1024; +const MAX_FRAME_SOCKET_BUFFERED_BYTES = 2 * 1024 * 1024; interface ResolvedViceEmbedLaunch { readonly command: string; @@ -85,6 +88,7 @@ export class CommodoreViceEmbedServiceImpl protected frameSocketServer: WebSocketServer | undefined; protected frameSockets = new Set(); protected latestBinaryFrame: Buffer | undefined; + protected clientConnectionCloseListener: Disposable | undefined; protected frameSocketUpgradeListener: | ((request: http.IncomingMessage, socket: Socket, head: Buffer) => void) | undefined; @@ -106,6 +110,8 @@ export class CommodoreViceEmbedServiceImpl this.frameSockets.clear(); this.frameSocketServer?.close(); this.frameSocketServer = undefined; + this.clientConnectionCloseListener?.dispose(); + this.clientConnectionCloseListener = undefined; this.client = undefined; } @@ -138,7 +144,17 @@ export class CommodoreViceEmbedServiceImpl } setClient(client: CommodoreViceEmbedClient | undefined): void { + this.clientConnectionCloseListener?.dispose(); + this.clientConnectionCloseListener = undefined; this.client = client; + this.clientConnectionCloseListener = observeRpcClientClose( + client, + (closedClient) => { + if (this.client === closedClient) { + this.handleClientDisconnected(); + } + } + ); } async launch(request: CommodoreViceEmbedLaunchRequest = {}): Promise { @@ -587,14 +603,28 @@ export class CommodoreViceEmbedServiceImpl if (!record || socket.readyState !== WebSocket.OPEN) { return; } + if (socket.bufferedAmount > MAX_FRAME_SOCKET_BUFFERED_BYTES) { + this.logger.warn( + `Closing slow VICE frame socket with ${socket.bufferedAmount} buffered bytes.` + ); + this.frameSockets.delete(socket); + socket.terminate(); + return; + } const maxBufferedBytes = Math.max( MIN_FRAME_SOCKET_BACKPRESSURE_BYTES, record.length ); - if (socket.bufferedAmount > maxBufferedBytes) { + if (socket.bufferedAmount > maxBufferedBytes || socket.bufferedAmount > 0) { return; } - socket.send(record, { binary: true }); + socket.send(record, { binary: true }, (error) => { + if (error) { + this.frameSockets.delete(socket); + socket.terminate(); + this.logger.warn(`VICE frame socket send failed: ${error.message}`); + } + }); } protected emitStatus(event: CommodoreViceEmbedStatusEvent): void { @@ -603,6 +633,12 @@ export class CommodoreViceEmbedServiceImpl this.logger.warn(event.message ?? 'Patched VICE embed reported an error.'); } } + + protected handleClientDisconnected(): void { + this.clientConnectionCloseListener?.dispose(); + this.clientConnectionCloseListener = undefined; + this.client = undefined; + } } function formatViceProcessCloseMessage( diff --git a/packages/theia-extension/src/node/kick-assembler-build-service-impl.ts b/packages/theia-extension/src/node/kick-assembler-build-service-impl.ts index 14e0ce4..328717f 100644 --- a/packages/theia-extension/src/node/kick-assembler-build-service-impl.ts +++ b/packages/theia-extension/src/node/kick-assembler-build-service-impl.ts @@ -6,6 +6,7 @@ import { fileURLToPath } from 'node:url'; import { PreferenceService } from '@theia/core/lib/common/preferences'; +import type { Disposable } from '@theia/core/lib/common/disposable'; import { inject, injectable } from '@theia/core/shared/inversify'; import { KickAssemblerWorkspaceBuildPlanner, @@ -46,6 +47,7 @@ import { import { getCommodoreCommanderToolPreferences } from '../common/commodore-commander-tool-preferences'; +import { observeRpcClientClose } from './rpc-client-lifecycle'; const DEFAULT_BUILD_CONFIG_FILE = 'commodore-commander.build.json'; const DEFAULT_PROFILE_NAME = 'debug'; @@ -76,17 +78,32 @@ export class KickAssemblerBuildServiceImpl implements KickAssemblerBuildService private readonly planner = new KickAssemblerWorkspaceBuildPlanner(); private client: KickAssemblerBuildClient | undefined; + private clientConnectionCloseListener: Disposable | undefined; private pendingRequest: KickAssemblerBuildRequest | undefined; private drainLoop: Promise | undefined; private buildConfigurationMutation: Promise = Promise.resolve(); dispose(): void { this.pendingRequest = undefined; + this.clientConnectionCloseListener?.dispose(); + this.clientConnectionCloseListener = undefined; this.client = undefined; } setClient(client: KickAssemblerBuildClient | undefined): void { + this.clientConnectionCloseListener?.dispose(); + this.clientConnectionCloseListener = undefined; this.client = client; + this.clientConnectionCloseListener = observeRpcClientClose( + client, + (closedClient) => { + if (this.client === closedClient) { + this.clientConnectionCloseListener?.dispose(); + this.clientConnectionCloseListener = undefined; + this.client = undefined; + } + } + ); } async build( diff --git a/packages/theia-extension/src/node/rpc-client-lifecycle.ts b/packages/theia-extension/src/node/rpc-client-lifecycle.ts new file mode 100644 index 0000000..ec94591 --- /dev/null +++ b/packages/theia-extension/src/node/rpc-client-lifecycle.ts @@ -0,0 +1,20 @@ +import type { Disposable, Event } from '@theia/core/lib/common'; + +interface RpcConnectionAwareClient { + readonly onDidCloseConnection?: Event; +} + +export function observeRpcClientClose( + client: T | undefined, + onClose: (client: T) => void +): Disposable | undefined { + if (!client) { + return undefined; + } + const onDidCloseConnection = + (client as RpcConnectionAwareClient).onDidCloseConnection; + if (typeof onDidCloseConnection !== 'function') { + return undefined; + } + return onDidCloseConnection.call(client, () => onClose(client)); +} diff --git a/packages/theia-extension/src/node/sidscore-runtime-service-impl.ts b/packages/theia-extension/src/node/sidscore-runtime-service-impl.ts index 8528595..cdcd820 100644 --- a/packages/theia-extension/src/node/sidscore-runtime-service-impl.ts +++ b/packages/theia-extension/src/node/sidscore-runtime-service-impl.ts @@ -7,6 +7,7 @@ import { fileURLToPath, pathToFileURL } from 'node:url'; import { inject, injectable } from '@theia/core/shared/inversify'; import type { BackendApplicationContribution } from '@theia/core/lib/node/backend-application'; +import type { Disposable } from '@theia/core/lib/common/disposable'; import { ILogger } from '@theia/core/lib/common/logger'; import { PreferenceService @@ -60,6 +61,7 @@ import { SID_SCORE_CLI_JAR_FILENAME, SID_SCORE_REQUIRED_JAVA_RELEASE } from './sidscore-launch'; +import { observeRpcClientClose } from './rpc-client-lifecycle'; export { SID_SCORE_CLI_JAR_FILENAME } from './sidscore-launch'; @@ -168,6 +170,7 @@ export class SidScoreRuntimeServiceImpl protected serverArgs: string[] = []; protected serverCwd = process.cwd(); protected readyServer: ReadyEvent | undefined; + protected clientConnectionCloseListener: Disposable | undefined; protected pendingVoiceState: SidScoreVoiceStateEvent | undefined; protected pendingScopeBuckets: SidScoreScopeBucketsEvent | undefined; protected pendingScopeSamples: SidScoreScopeSamplesEvent | undefined; @@ -181,6 +184,8 @@ export class SidScoreRuntimeServiceImpl new Error('SIDScore player server was disposed.') ); this.stopServerProcess(); + this.clientConnectionCloseListener?.dispose(); + this.clientConnectionCloseListener = undefined; this.client = undefined; } @@ -189,7 +194,17 @@ export class SidScoreRuntimeServiceImpl } setClient(client: SidScoreRuntimeClient | undefined): void { + this.clientConnectionCloseListener?.dispose(); + this.clientConnectionCloseListener = undefined; this.client = client; + this.clientConnectionCloseListener = observeRpcClientClose( + client, + (closedClient) => { + if (this.client === closedClient) { + this.handleClientDisconnected(); + } + } + ); } async play(request: SidScorePlayRequest): Promise { @@ -886,7 +901,7 @@ export class SidScoreRuntimeServiceImpl } protected queueVoiceState(event: SidScoreVoiceStateEvent): void { - if (this.pendingPlaybackRequestId !== undefined) { + if (!this.client || this.pendingPlaybackRequestId !== undefined) { return; } if ( @@ -900,7 +915,7 @@ export class SidScoreRuntimeServiceImpl } protected queueScopeBuckets(event: SidScoreScopeBucketsEvent): void { - if (this.pendingPlaybackRequestId !== undefined) { + if (!this.client || this.pendingPlaybackRequestId !== undefined) { return; } if ( @@ -914,7 +929,7 @@ export class SidScoreRuntimeServiceImpl } protected queueScopeSamples(event: SidScoreScopeSamplesEvent): void { - if (this.pendingPlaybackRequestId !== undefined) { + if (!this.client || this.pendingPlaybackRequestId !== undefined) { return; } const pending = this.pendingScopeSamples; @@ -979,6 +994,13 @@ export class SidScoreRuntimeServiceImpl this.pendingScopeSamples = undefined; } + protected handleClientDisconnected(): void { + this.clientConnectionCloseListener?.dispose(); + this.clientConnectionCloseListener = undefined; + this.client = undefined; + this.clearPendingTelemetry(); + } + protected sendFrame( type: number, payload: Buffer = Buffer.alloc(0), diff --git a/packages/theia-extension/src/test/commodore-vice-embed-service-impl.test.ts b/packages/theia-extension/src/test/commodore-vice-embed-service-impl.test.ts index 24ec85a..a28ba27 100644 --- a/packages/theia-extension/src/test/commodore-vice-embed-service-impl.test.ts +++ b/packages/theia-extension/src/test/commodore-vice-embed-service-impl.test.ts @@ -2,6 +2,10 @@ import assert from 'node:assert/strict'; import net from 'node:net'; import { test } from 'node:test'; +import type { + CommodoreViceEmbedClient, + CommodoreViceEmbedStatusEvent +} from '../common/commodore-vice-embed-service'; import { CommodoreViceEmbedServiceImpl } from '../node/commodore-vice-embed-service-impl'; test('embedded debug frame transport reuses a reserved port', async (t) => { @@ -31,6 +35,21 @@ test('embedded debug frame transport refuses to replace a connected emulator', a assert.equal(service.frameServerStartCount, 1); }); +test('embedded VICE service drops frontend client when the RPC connection closes', () => { + const service = new TestViceEmbedService(); + const client = new TestViceEmbedClient(); + service.setClient(client); + + service.emitTestStatus({ state: 'running', message: 'before close' }); + client.closeConnection(); + service.emitTestStatus({ state: 'running', message: 'after close' }); + + assert.deepEqual( + client.statuses.map((status) => status.message), + ['before close'] + ); +}); + class TestViceEmbedService extends CommodoreViceEmbedServiceImpl { frameServerStartCount = 0; @@ -42,6 +61,10 @@ class TestViceEmbedService extends CommodoreViceEmbedServiceImpl { return this.viceFrameSocket !== undefined; } + emitTestStatus(event: CommodoreViceEmbedStatusEvent): void { + this.emitStatus(event); + } + protected override async startViceFrameServer( closeWhenSocketCloses: boolean ): Promise { @@ -50,6 +73,32 @@ class TestViceEmbedService extends CommodoreViceEmbedServiceImpl { } } +class TestViceEmbedClient implements CommodoreViceEmbedClient { + readonly statuses: CommodoreViceEmbedStatusEvent[] = []; + private readonly closeListeners = new Set<() => void>(); + + readonly onDidCloseConnection = (listener: () => void): { dispose(): void } => { + this.closeListeners.add(listener); + return { + dispose: () => this.closeListeners.delete(listener) + }; + }; + + onViceEmbedFrame(): void {} + + onViceEmbedStatus(event: CommodoreViceEmbedStatusEvent): void { + this.statuses.push(event); + } + + onViceEmbedOutput(): void {} + + closeConnection(): void { + for (const listener of this.closeListeners) { + listener(); + } + } +} + function connectLoopback(port: number): Promise { const socket = net.connect({ host: '127.0.0.1', port }); return new Promise((resolve, reject) => { diff --git a/packages/theia-extension/src/test/sidscore-runtime-client-lifecycle.test.ts b/packages/theia-extension/src/test/sidscore-runtime-client-lifecycle.test.ts new file mode 100644 index 0000000..6b935d0 --- /dev/null +++ b/packages/theia-extension/src/test/sidscore-runtime-client-lifecycle.test.ts @@ -0,0 +1,73 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import type { + SidScoreRuntimeClient, + SidScoreScopeSamplesEvent +} from '../common/sidscore-runtime-service'; +import { SidScoreRuntimeServiceImpl } from '../node/sidscore-runtime-service-impl'; + +test('SIDScore runtime clears telemetry when the frontend RPC connection closes', (t) => { + const service = new TestSidScoreRuntimeService(); + t.after(() => service.dispose()); + + const client = new TestSidScoreRuntimeClient(); + service.setClient(client); + service.queueTestScopeSamples(scopeSamplesEvent([1, 2, 3])); + assert.equal(service.hasPendingTelemetry(), true); + + client.closeConnection(); + assert.equal(service.hasPendingTelemetry(), false); + + service.queueTestScopeSamples(scopeSamplesEvent([4, 5, 6])); + assert.equal(service.hasPendingTelemetry(), false); +}); + +class TestSidScoreRuntimeService extends SidScoreRuntimeServiceImpl { + queueTestScopeSamples(event: SidScoreScopeSamplesEvent): void { + this.queueScopeSamples(event); + } + + hasPendingTelemetry(): boolean { + return Boolean( + this.pendingVoiceState || + this.pendingScopeBuckets || + this.pendingScopeSamples || + this.telemetryFlushTimer + ); + } +} + +class TestSidScoreRuntimeClient implements SidScoreRuntimeClient { + private readonly closeListeners = new Set<() => void>(); + + readonly onDidCloseConnection = (listener: () => void): { dispose(): void } => { + this.closeListeners.add(listener); + return { + dispose: () => this.closeListeners.delete(listener) + }; + }; + + onSidScoreScopeSamples(): void {} + + closeConnection(): void { + for (const listener of this.closeListeners) { + listener(); + } + } +} + +function scopeSamplesEvent(samples: readonly number[]): SidScoreScopeSamplesEvent { + return { + scoreId: 'score', + blockIndex: '0', + sampleRate: 44100, + sampleCount: samples.length, + voices: [ + { + voiceIndex: 1, + samples + } + ] + }; +}