[Bug]: Disabling the GNOME extension leaves an overlapping activation wait running #35974
Send to my agentSummary#
Disabling the GNOME SnapShot extension does not cancel every pending activation when the same capture caller sends overlapping activation requests while its window is unavailable. One request, its window-map signal, and its timeout remain active after disposal. An isolated fixture reproduced this cleanup failure against the unchanged production class; a single pending request cleaned up correctly.
Before submitting#
- Related issues: listed under Additional context; no matching report found in targeted open and closed issue and PR searches.
- Removed credentials, tokens, private paths, and sensitive data.
Area#
apps/desktop
Actual behavior#
In a synthetic Shell/GI fixture, two pending CaptureFeedback.activate() calls from the same valid capture sender registered two window-map signal handlers and two activation timers. Calling dispose() removed only the second activation wait: one map handler, one activation timeout, and one unsettled request remained. The first request settled only when its retained timeout ran. The one-request control removed all signals, timers, and watches and settled its request immediately.
Steps to reproduce#
- Load the production
apps/desktop/gnome-extension/captureFeedback.jsclass into inert GI/Shell boundaries, matching the approach of its existing unit tests. - Begin a capture session for one sender and arrange that no destination window is currently mapped, a condition the production method explicitly supports.
- Call
activate()twice for that sender without waiting for the first call, then calldispose(), which the extension invokes during disable. - Inspect registered signal handlers, timers, and request completion before firing synthetic timers.
The fixture reproduced the failure deterministically in one run. It did not start GNOME Shell, install or enable an extension, use a session bus, capture a window, or read application state. This proves the class cleanup failure under overlapping valid requests; it does not establish how often the desktop UI sends overlapping requests.
Save the following as repro-activation-cleanup.mjs and run node repro-activation-cleanup.mjs /path/to/checkout. It needs Node with standard built-in modules only, reads the checkout without modifying it, and places HOME, XDG, and application state directories under a temporary directory that it removes. Exit 1 means the defect reproduced, exit 0 means all activation requests and resources were cleaned up, and exit 2 means the setup or control failed. The script replaces imports with inert boundary fixtures and otherwise executes the production class unchanged.
Reproduction script: repro-activation-cleanup.mjs
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import vm from 'node:vm';
console.log(`Node ${process.version}`);
let result = 'inconclusive';
let status = 2;
let scratch;
try {
const checkout = process.argv[2];
if (!checkout) throw new Error('Pass the checkout path as the first argument.');
scratch = await fs.mkdtemp(path.join(os.tmpdir(), 't3-activation-repro-'));
for (const key of ['HOME', 'TMPDIR', 'XDG_DATA_HOME', 'XDG_CONFIG_HOME', 'XDG_STATE_HOME', 'XDG_CACHE_HOME', 'T3CODE_HOME']) {
process.env[key] = path.join(scratch, key.toLowerCase());
await fs.mkdir(process.env[key], { recursive: true });
}
const source = await fs.readFile(path.join(checkout, 'apps/desktop/gnome-extension/captureFeedback.js'), 'utf8');
// Only replace module imports with inert GI/Shell boundaries, as in the existing unit tests.
const body = source.replace(/^import .*;\r?\n/gm, '').replace('export class CaptureFeedback', 'class CaptureFeedback');
function fixture() {
let next = 1;
const mapped = new Map();
const timers = new Map();
const watches = new Set();
const context = {
Gio: {
DBus: { session: {} }, BusNameWatcherFlags: { NONE: 0 },
bus_watch_name_on_connection() { const id = next++; watches.add(id); return id; },
bus_unwatch_name(id) { watches.delete(id); },
},
GLib: {
PRIORITY_DEFAULT: 0, SOURCE_REMOVE: false,
timeout_add(_priority, delay, callback) { const id = next++; timers.set(id, {delay, callback}); return id; },
source_remove(id) { timers.delete(id); },
},
Meta: { WindowType: { NORMAL: 0 } },
St: { Settings: { get: () => ({ enable_animations: false }) } },
Main: { sessionMode: { isLocked: false, isGreeter: false } },
global: {
get_window_actors: () => [],
window_manager: {
connect(_name, callback) { const id = next++; mapped.set(id, callback); return id; },
disconnect(id) { mapped.delete(id); },
},
},
findCaptureDestination: () => undefined,
};
const CaptureFeedback = vm.runInNewContext(`${body}\nCaptureFeedback;`, context);
const feedback = new CaptureFeedback();
feedback.begin(':1.23', 42, {}, { flash: false, animate: false });
return { feedback, mapped, timers, watches };
}
const control = fixture();
let controlSettled = false;
const controlRequest = control.feedback.activate(':1.23', 'T3').catch(() => { controlSettled = true; });
control.feedback.dispose();
await controlRequest;
if (!controlSettled || control.mapped.size || control.timers.size || control.watches.size) throw new Error('Single-request cleanup control failed.');
console.log('control: single activation disposed; 0 signals, 0 timers, 0 watches, request settled');
const test = fixture();
let settled = 0;
const first = test.feedback.activate(':1.23', 'T3').then(() => settled++, () => settled++);
const second = test.feedback.activate(':1.23', 'T3').then(() => settled++, () => settled++);
if (test.mapped.size !== 2) throw new Error('Concurrent activation setup failed.');
test.feedback.dispose();
await second;
await Promise.resolve();
console.log(`concurrent after dispose: ${test.mapped.size} signal(s), ${test.timers.size} timer(s), ${test.watches.size} watch(es), ${settled}/2 requests settled`);
const clean = test.mapped.size === 0 && test.timers.size === 0 && test.watches.size === 0 && settled === 2;
if (!clean && !(test.mapped.size === 1 && test.timers.size === 1 && test.watches.size === 0 && settled === 1)) throw new Error('Unexpected cleanup outcome.');
// Advance the retained synthetic timeout, so the fixture itself leaves no pending request.
for (const [id, timer] of [...test.timers]) { test.timers.delete(id); timer.callback(); }
await Promise.all([first, second]);
if (test.mapped.size || test.timers.size || settled !== 2) throw new Error('Fixture teardown failed.');
result = clean ? 'expected behavior' : 'defect reproduced';
status = clean ? 0 : 1;
} catch (error) {
console.log(`setup: ${error.message}`);
} finally {
if (scratch) await fs.rm(scratch, { recursive: true, force: true });
}
// Terminal line: RESULT: expected behavior (0), RESULT: defect reproduced (1), or RESULT: inconclusive (2).
console.log(`RESULT: ${result}`);
process.exitCode = status;
Expected behavior#
Disabling the extension should disconnect every window-map signal, remove every activation timeout, and settle every outstanding activation invocation. GNOME's lifecycle rules require cleanup of signal connections, and its main-loop source rule explicitly requires removal even when a callback would eventually return GLib.SOURCE_REMOVE. The extension's own disable path calls feedback disposal for this purpose.
Impact#
Minor bug or occasional failure
Impact: P3 - A bounded lifecycle cleanup failure leaves one signal, one timeout, and one request alive after disposal; no consequential capture or UI failure was demonstrated.
This affects overlapping Activate calls on the same capture session while its destination window is missing. The production D-Bus interface accepts these calls independently and has no in-flight activation restriction. The retained activation timeout is configured for 1500 ms, so the fixture establishes a temporary leak rather than a permanent one. A single pending activation cleans up correctly. Avoiding overlapping activation requests avoids this case. NOT_ENOUGH_INFO: occurrence through the current desktop UI or behavior on a real GNOME session was not measured; a controlled GNOME run would establish that scope.
Version or commit#
T3 Code commit 59e64eef881632a23f89c4138bcf37f23d92bf93; desktop package version 0.1.9. Production source was not modified.
Environment#
Node v26.12.1 on macOS; pure JavaScript fixture with inert GI/Shell boundary implementations. No GNOME runtime was used. The source declares Shell majors 45–51, but those real sessions were not run.
Interface#
API: the GNOME extension's Activate D-Bus method and its feedback lifecycle.
Earliest failure stage#
Cleanup or lifecycle
Relevant output or evidence#
Node v26.12.1
control: single activation disposed; 0 signals, 0 timers, 0 watches, request settled
concurrent after dispose: 1 signal(s), 1 timer(s), 0 watch(es), 1/2 requests settled
RESULT: defect reproduced
- apps/desktop/gnome-extension/captureFeedback.js#L73-L110 starts each activation wait independently and replaces the shared cancellation callback.
- apps/desktop/gnome-extension/captureFeedback.js#L156-L166 invokes only the current activation cancellation callback during disposal.
- apps/desktop/gnome-extension/extension.js#L26-L26 exports
Activate; apps/desktop/gnome-extension/extension.js#L164-L169 passes each invocation toactivate(); apps/desktop/gnome-extension/extension.js#L180-L192 calls disposal during disable.
Analysis or suspected cause#
Source: _cancelActivation is one field shared by all activation invocations. Starting a second wait overwrites the first wait's cancellation function, so disposal can cancel only the last wait. The first wait retains its own signal connection and local timeout until it observes a map or its timeout fires.
Additional context#
- https://github.com/pingdotgg/t3code/issues/12589 — concerns shortcut registration on older GNOME versions, rather than pending activation cleanup.
- https://github.com/pingdotgg/t3code/pull/35455 — implements that shortcut fallback and lock recovery; its capture-feedback activation code does not cover this overlapping-wait case.
tanstack-source added labelsbug
juliusmarminge closed this
Sign in to join this conversation.
I tried to reproduce this on the current build and could not.
On origin/main b7a224c7dd I added a test to apps/desktop/gnome-extension/captureFeedback.test.js. It uses that file's existing GI/Shell mocks, begins a capture for ':1.23' with no T3 window mapped, calls activate(':1.23','T3') twice without awaiting, then calls dispose(). Two map signals are connected before dispose and one is still connected after it (expected 0, got 1). The other four tests pass. This shows the class-level cleanup leak the reporter describes. I did not run a real GNOME Shell session with the extension enabled, so it was not observed in a live app.
Closing as not planned. If it still happens for you, reply with the exact steps and the version you are on and it will be looked at again.