Skip to content

Commit 77a5c35

Browse files
committed
fix race condition leading to a crash in issue #64
1 parent 6ce7c66 commit 77a5c35

File tree

4 files changed

+166
-11
lines changed

4 files changed

+166
-11
lines changed

lib/state-matcher.js

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -147,14 +147,8 @@ class WindowStateMatcher {
147147
this.restoreFromState(initialState)
148148
}
149149

150-
// Query current actors if callback is provided
151-
if (this.actorQueryCallback) {
152-
const result = this.updateFromCurrentActors()
153-
// Pass results to callback for stat tracking
154-
if (result && (result.operations.length > 0 || result.events.length > 0) && this.onProcessingCallback) {
155-
this.onProcessingCallback(result)
156-
}
157-
}
150+
// Note: initial actor query is deferred to caller via updateFromCurrentActors()
151+
// to ensure all references (like OperationHandler._tracker) are set up first
158152

159153
this.startProcessingTimer()
160154
}

lib/state-session.js

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,14 @@ export class StateSession {
108108
// Give the operation handler a reference to the tracker for callbacks
109109
this._trackerHandler._tracker = this._tracker
110110

111+
// Process initial actors now that all references are set up
112+
if (this._actorQueryCallback) {
113+
const result = this._tracker.updateFromCurrentActors()
114+
if (result && (result.operations.length > 0 || result.events.length > 0)) {
115+
this._onAsyncProcessing(result)
116+
}
117+
}
118+
111119
debug('session', 'StateSession initialized')
112120
}
113121

@@ -507,7 +515,9 @@ export class OperationHandler {
507515
this._pendingMoves.delete(winid)
508516

509517
// Notify matcher that all operations are complete for this window
510-
this._tracker.onOperationsComplete(winid)
518+
if (this._tracker) {
519+
this._tracker.onOperationsComplete(winid)
520+
}
511521
}
512522

513523
_executeOperations(operations) {
@@ -575,7 +585,7 @@ export class OperationHandler {
575585

576586
// Notify matcher that operations are complete for windows without pending moves
577587
for (const winid of windowsWithOps) {
578-
if (!this._pendingMoves.has(winid)) {
588+
if (!this._pendingMoves.has(winid) && this._tracker) {
579589
this._tracker.onOperationsComplete(winid)
580590
}
581591
}

metadata.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,5 +6,5 @@
66
"settings-schema": "org.gnome.shell.extensions.smart-auto-move",
77
"original-author": "khimaros",
88
"shell-version": ["49"],
9-
"version": "42"
9+
"version": "43"
1010
}
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
"""
2+
Story 8: Extension initialization with existing windows
3+
4+
Regression test for the bug where enabling the extension with existing windows
5+
caused a crash because WindowStateMatcher's onProcessingCallback fired during
6+
construction, before OperationHandler._tracker was assigned.
7+
8+
The fix ensures initial actor processing is deferred until after all references
9+
are set up in StateSession.
10+
"""
11+
12+
import pytest
13+
import time
14+
from vmtest import (
15+
WindowControlClient, ExtensionState, launch_app, close_app,
16+
wait_for_settle, poll_until
17+
)
18+
19+
20+
class TestStory8:
21+
"""Story 8: Extension initialization with existing windows."""
22+
23+
@pytest.fixture(autouse=True)
24+
def skip_clean_state(self, request):
25+
"""Override autouse clean_state fixture for this test module."""
26+
# We need to control extension enable/disable ourselves
27+
pass
28+
29+
def test_enable_with_existing_windows_and_saved_state(self, wc_client, ext_state):
30+
"""Test that extension can be enabled when windows exist AND there's saved state.
31+
32+
This is a regression test for the bug where WindowStateMatcher's
33+
constructor synchronously called onProcessingCallback before
34+
OperationHandler._tracker was assigned, causing:
35+
36+
TypeError: can't access property "onOperationsComplete", this._tracker is undefined
37+
38+
The bug triggers when:
39+
1. Extension enables with existing windows
40+
2. There's saved state that matches those windows
41+
3. Window position DIFFERS from saved state (so operations are generated)
42+
"""
43+
# 1. Start with extension enabled and clean state
44+
ext_state.disable_extension()
45+
ext_state.reset_settings()
46+
ext_state.enable_extension()
47+
ext_state.enable_debug_logging(True)
48+
49+
# 2. Open a calculator window and move it so extension saves state
50+
win, proc = launch_app(wc_client, ["gnome-calculator"], "org.gnome.Calculator")
51+
wait_for_settle(1.0)
52+
53+
try:
54+
# 3. Move the window to create saved state at position A
55+
initial_details = wc_client.get_details(win.id)
56+
saved_x = initial_details.x + 100
57+
saved_y = initial_details.y + 100
58+
wc_client.move(win.id, saved_x, saved_y)
59+
wait_for_settle(2.0)
60+
61+
# Wait for state to be saved (poll until dconf has data)
62+
def check_saved():
63+
return ext_state.get_saved_windows()
64+
saved = poll_until(check_saved, timeout=5.0, poll=0.5)
65+
assert saved, "Extension should have saved window state"
66+
67+
# 4. Disable extension but KEEP the saved state
68+
ext_state.disable_extension()
69+
wait_for_settle(0.5)
70+
71+
# 5. Move window to position B while extension is disabled
72+
# This creates the drift that will trigger restore operations on enable
73+
drifted_x, drifted_y = saved_x + 200, saved_y + 200
74+
wc_client.move(win.id, drifted_x, drifted_y)
75+
wait_for_settle(1.0)
76+
77+
# Verify the move actually happened
78+
moved_details = wc_client.get_details(win.id)
79+
assert moved_details.x == drifted_x, f"Window should have moved to x={drifted_x}, got {moved_details.x}"
80+
assert moved_details.y == drifted_y, f"Window should have moved to y={drifted_y}, got {moved_details.y}"
81+
82+
# 6. Re-enable extension - this is where the bug would crash
83+
# The extension sees window at B, saved state says A,
84+
# generates operations to restore to A during construction
85+
ext_state.enable_extension()
86+
wait_for_settle(1.0)
87+
88+
# 7. Verify extension is still working by moving the window
89+
# If the extension crashed during init, this would fail
90+
details = wc_client.get_details(win.id)
91+
wc_client.move(win.id, details.x + 25, details.y + 25)
92+
wait_for_settle(1.0)
93+
94+
finally:
95+
close_app(wc_client, win.id, proc)
96+
97+
def test_enable_with_drifted_window_position(self, wc_client, ext_state):
98+
"""Test enabling extension when window position differs from saved state.
99+
100+
This specifically tests the scenario where restore operations are generated
101+
during initialization because the window has moved from its saved position.
102+
"""
103+
# 1. Start with extension enabled and clean state
104+
ext_state.disable_extension()
105+
ext_state.reset_settings()
106+
ext_state.enable_extension()
107+
ext_state.enable_debug_logging(True)
108+
109+
# 2. Open a window
110+
win, proc = launch_app(wc_client, ["gnome-calculator"], "org.gnome.Calculator")
111+
wait_for_settle(1.0)
112+
113+
try:
114+
# 3. Move window to create saved state at position A
115+
initial = wc_client.get_details(win.id)
116+
saved_x, saved_y = initial.x + 50, initial.y + 50
117+
wc_client.move(win.id, saved_x, saved_y)
118+
wait_for_settle(2.0)
119+
120+
# Wait for state to be saved (poll until dconf has data)
121+
def check_saved():
122+
return ext_state.get_saved_windows()
123+
saved = poll_until(check_saved, timeout=5.0, poll=0.5)
124+
assert saved, "Extension should have saved window state"
125+
126+
# 4. Disable extension
127+
ext_state.disable_extension()
128+
wait_for_settle(0.5)
129+
130+
# 5. Move window to different position while disabled
131+
drifted_x, drifted_y = saved_x + 150, saved_y + 150
132+
wc_client.move(win.id, drifted_x, drifted_y)
133+
wait_for_settle(1.0)
134+
135+
# Verify the move actually happened
136+
moved_details = wc_client.get_details(win.id)
137+
assert moved_details.x == drifted_x, f"Window should have moved to x={drifted_x}, got {moved_details.x}"
138+
assert moved_details.y == drifted_y, f"Window should have moved to y={drifted_y}, got {moved_details.y}"
139+
140+
# 6. Re-enable - this triggers restore operations during init
141+
ext_state.enable_extension()
142+
wait_for_settle(1.0)
143+
144+
# 7. Verify extension is working by moving the window
145+
# If the extension crashed during init, this would fail
146+
details = wc_client.get_details(win.id)
147+
wc_client.move(win.id, details.x + 25, details.y + 25)
148+
wait_for_settle(1.0)
149+
150+
finally:
151+
close_app(wc_client, win.id, proc)

0 commit comments

Comments
 (0)