Skip to content
Source
Sign inSign up

[Bug]: Disabling the GNOME extension leaves an overlapping activation wait running #35974

Send to my agent
Closedcoygeek(GitHub profile, opens in a new tab) opened this issue 1 comment

Summary

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

  1. Load the production apps/desktop/gnome-extension/captureFeedback.js class into inert GI/Shell boundaries, matching the approach of its existing unit tests.
  2. Begin a capture session for one sender and arrange that no destination window is currently mapped, a condition the production method explicitly supports.
  3. Call activate() twice for that sender without waiting for the first call, then call dispose(), which the extension invokes during disable.
  4. 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

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.
  1. tanstack-source added labelsbug

  2. juliusmarmingecommented

    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.

  3. juliusmarminge closed this

Sign in to join this conversation.