From cd007b155735f24664a06c69f9413fcdf9293349 Mon Sep 17 00:00:00 2001 From: Leandro Rodrigues Date: Sat, 8 Aug 2026 22:24:14 -0300 Subject: [PATCH] fix(dock): manage batched intellihide timeouts Replace pending staggered refreshes before scheduling a new batch and tie every source to the Intellihide lifecycle. Update the shell integration fixture to cancel the managed refresh and settle sources, and add policy coverage that prevents feature code from creating unmanaged main-loop sources. --- docs/extension-review.md | 6 ++-- src/core/mainLoop.ts | 47 ++++++++++++++++++++++++++++ src/dock/intellihide.ts | 23 +++++--------- tests/shell/dock/dock.test.js | 11 ++----- tests/unit/project/egoPolicy.test.ts | 43 +++++++++++++++++++++++++ 5 files changed, 103 insertions(+), 27 deletions(-) diff --git a/docs/extension-review.md b/docs/extension-review.md index c6d42f8..8bec48d 100644 --- a/docs/extension-review.md +++ b/docs/extension-review.md @@ -62,9 +62,9 @@ Capture Tools destroys its session actors directly in `disable()`. Replaceable m owned by `LifecycleScope` through `ManagedSource`; replacing a source removes the previous one and disposing the scope removes the active source. Aurora Dash, Dock bindings, Clipboard History, Auto Theme Switcher, clocks, tray widgets, Bluetooth, and the remaining single-source owners use this -path. Dynamic source collections in Icon Weave and Dock Intellihide remain explicitly removed because -their per-operation ownership is clearer as a set. Shexli does not currently report `EGO-L-004` for -either cleanup form. +path. Dynamic inspection sources in Icon Weave use short-lived lifecycle scopes, and Dock Intellihide +uses a managed timeout batch for staggered refreshes. No production main-loop source bypasses this +ownership path. Shexli does not currently report `EGO-L-004` for this cleanup form. Recheck this classification against every new Shexli run. A stable rule ID does not imply that new locations are automatically accepted. diff --git a/src/core/mainLoop.ts b/src/core/mainLoop.ts index a7a39e0..d935da5 100644 --- a/src/core/mainLoop.ts +++ b/src/core/mainLoop.ts @@ -2,6 +2,47 @@ import GLib from '@girs/glib-2.0'; import type { LifecycleScope, ManagedSource } from '~/core/lifecycleScope.ts'; +export interface ManagedTimeoutBatch { + replace(delays: readonly number[], callback: () => void): void; + clear(): void; +} + +class ManagedTimeoutBatchImpl implements ManagedTimeoutBatch { + private _sourceIds: Set | null = new Set(); + + replace(delays: readonly number[], callback: () => void): void { + this.clear(); + if (!this._sourceIds) return; + + for (const delay of delays) { + const sourceId = GLib.timeout_add(GLib.PRIORITY_DEFAULT, delay, () => { + if (!this._sourceIds) return GLib.SOURCE_REMOVE; + + this._sourceIds.delete(sourceId); + callback(); + return GLib.SOURCE_REMOVE; + }); + this._sourceIds.add(sourceId); + } + } + + clear(): void { + if (!this._sourceIds) return; + + for (const sourceId of this._sourceIds) { + removeSource(sourceId); + } + this._sourceIds.clear(); + } + + dispose(): void { + if (!this._sourceIds) return; + + this.clear(); + this._sourceIds = null; + } +} + export function removeSource(sourceId: number): 0 { if (sourceId !== 0) GLib.source_remove(sourceId); return 0; @@ -16,3 +57,9 @@ export function removeSource(sourceId: number): 0 { export function createManagedSource(scope: LifecycleScope): ManagedSource { return scope.manageSource(removeSource); } + +export function createManagedTimeoutBatch(scope: LifecycleScope): ManagedTimeoutBatch { + const batch = new ManagedTimeoutBatchImpl(); + scope.onDispose(() => batch.dispose()); + return batch; +} diff --git a/src/dock/intellihide.ts b/src/dock/intellihide.ts index cabb735..d07ee36 100644 --- a/src/dock/intellihide.ts +++ b/src/dock/intellihide.ts @@ -8,7 +8,11 @@ import * as Main from '@girs/gnome-shell/ui/main'; import { LifecycleScope, type ManagedSource } from '~/core/lifecycleScope.ts'; import { logger } from '~/core/logger.ts'; -import { createManagedSource } from '~/core/mainLoop.ts'; +import { + createManagedSource, + createManagedTimeoutBatch, + type ManagedTimeoutBatch, +} from '~/core/mainLoop.ts'; import { getBlockingOverlapState, isOnActiveWorkspace, @@ -74,7 +78,7 @@ export const DockIntellihide = GObject.registerClass( private _excludePipFromSmartReveal = false; declare private _trackedWindowActors: Set; declare private _trackedWindows: Set; - declare private _queuedRefreshIds: Set; + declare private _queuedRefreshes: ManagedTimeoutBatch; override _init(monitorIndex: number, excludePipFromSmartReveal = false) { super._init(); @@ -84,7 +88,7 @@ export const DockIntellihide = GObject.registerClass( this._excludePipFromSmartReveal = excludePipFromSmartReveal; this._trackedWindowActors = new Set(); this._trackedWindows = new Set(); - this._queuedRefreshIds = new Set(); + this._queuedRefreshes = createManagedTimeoutBatch(this._lifecycle); global.display.connectObject( 'window-entered-monitor', @@ -157,10 +161,6 @@ export const DockIntellihide = GObject.registerClass( destroy(): void { this._cancelPendingStatus(); this._lifecycle.dispose(); - for (const id of this._queuedRefreshIds) { - GLib.source_remove(id); - } - this._queuedRefreshIds.clear(); this._clearTrackedWindows(); global.display.disconnectObject(this); Main.layoutManager.disconnectObject(this); @@ -224,14 +224,7 @@ export const DockIntellihide = GObject.registerClass( } private _queueRefresh(reason: string, delays: number[] = [0]): void { - for (const delay of delays) { - const id = GLib.timeout_add(GLib.PRIORITY_DEFAULT, delay, () => { - this._queuedRefreshIds.delete(id); - this._checkOverlap(reason); - return GLib.SOURCE_REMOVE; - }); - this._queuedRefreshIds.add(id); - } + this._queuedRefreshes.replace(delays, () => this._checkOverlap(reason)); } private _isMonitorValid(): boolean { diff --git a/tests/shell/dock/dock.test.js b/tests/shell/dock/dock.test.js index 74c711a..9ae0f04 100644 --- a/tests/shell/dock/dock.test.js +++ b/tests/shell/dock/dock.test.js @@ -4,7 +4,6 @@ import * as Main from 'resource:///org/gnome/shell/ui/main.js'; import * as Scripting from 'resource:///org/gnome/shell/ui/scripting.js'; import Clutter from 'gi://Clutter'; import Gio from 'gi://Gio'; -import GLib from 'gi://GLib'; import Shell from 'gi://Shell'; import St from 'gi://St'; import { @@ -29,14 +28,8 @@ function findDockActor() { } function clearIntellihideQueuedRefreshes(intellihide) { - for (const id of intellihide._queuedRefreshIds || []) { - GLib.source_remove(id); - } - if (intellihide._queuedRefreshIds) intellihide._queuedRefreshIds.clear(); - if (intellihide._settleId) { - GLib.source_remove(intellihide._settleId); - intellihide._settleId = 0; - } + intellihide._queuedRefreshes?.clear(); + intellihide._settle?.clear(); } export var METRICS = {}; diff --git a/tests/unit/project/egoPolicy.test.ts b/tests/unit/project/egoPolicy.test.ts index cebe56a..006deb9 100644 --- a/tests/unit/project/egoPolicy.test.ts +++ b/tests/unit/project/egoPolicy.test.ts @@ -141,3 +141,46 @@ test('lifecycle methods are not empty placeholders', () => { assert.deepEqual(violations, []); }); + +test('main-loop sources are created through replaceable lifecycle owners', () => { + const violations: string[] = []; + const sourceCreators = new Set(['idle_add', 'timeout_add', 'timeout_add_seconds']); + + for (const file of sourceFiles(sourceRoot).filter((path) => path.endsWith('.ts'))) { + if (file.endsWith(join('core', 'mainLoop.ts'))) continue; + + const source = readFileSync(file, 'utf8'); + const tree = ts.createSourceFile(file, source, ts.ScriptTarget.Latest, true); + const visit = (node: ts.Node): void => { + if ( + ts.isCallExpression(node) && + ts.isPropertyAccessExpression(node.expression) && + node.expression.expression.getText(tree) === 'GLib' && + sourceCreators.has(node.expression.name.text) + ) { + let owner: ts.Node | undefined = node.parent; + let managed = false; + while (owner && !ts.isMethodDeclaration(owner) && !ts.isFunctionDeclaration(owner)) { + if ( + ts.isCallExpression(owner) && + ts.isPropertyAccessExpression(owner.expression) && + owner.expression.name.text === 'replace' + ) { + managed = true; + break; + } + owner = owner.parent; + } + + if (!managed) { + const line = tree.getLineAndCharacterOfPosition(node.getStart(tree)).line + 1; + violations.push(`${file.slice(sourceRoot.length + 1)}:${line}`); + } + } + ts.forEachChild(node, visit); + }; + visit(tree); + } + + assert.deepEqual(violations, []); +});