Merge lp:~robru/friends/keepalive into lp:~super-friends/friends/raring
- keepalive
- Merge into raring
Proposed by
Robert Bruce Park
Status: | Merged |
---|---|
Merged at revision: | 181 |
Proposed branch: | lp:~robru/friends/keepalive |
Merge into: | lp:~super-friends/friends/raring |
Diff against target: |
380 lines (+132/-36) 5 files modified
friends/main.py (+1/-7) friends/protocols/twitter.py (+0/-1) friends/service/dispatcher.py (+56/-6) friends/tests/test_dispatcher.py (+75/-10) friends/utils/base.py (+0/-12) |
To merge this branch: | bzr merge lp:~robru/friends/keepalive |
Related bugs: |
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
Ken VanDine | Approve | ||
Review via email: mp+154235@code.launchpad.net |
Commit message
Keep the Dispatcher alive for 30s beyond the return of the final method invocation.
Description of the change
To post a comment you must log in.
Revision history for this message
Ken VanDine (ken-vandine) : | # |
review:
Approve
Preview Diff
[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1 | === modified file 'friends/main.py' |
2 | --- friends/main.py 2013-03-14 19:44:13 +0000 |
3 | +++ friends/main.py 2013-03-20 01:26:22 +0000 |
4 | @@ -62,11 +62,9 @@ |
5 | GObject.threads_init(None) |
6 | |
7 | from friends.service.dispatcher import Dispatcher, DBUS_INTERFACE |
8 | -from friends.utils.base import _OperationThread, _publish_lock |
9 | -from friends.utils.base import Base, initialize_caches |
10 | +from friends.utils.base import Base, initialize_caches, _publish_lock |
11 | from friends.utils.model import Model, prune_model |
12 | from friends.utils.logging import initialize |
13 | -from friends.utils.avatar import Avatar |
14 | |
15 | |
16 | # Optional performance profiling module. |
17 | @@ -89,10 +87,6 @@ |
18 | print(class_name) |
19 | return |
20 | |
21 | - # Our threading implementation needs to know how to quit the |
22 | - # application once all threads have completed. |
23 | - _OperationThread.shutdown = loop.quit |
24 | - |
25 | # Disallow multiple instances of friends-dispatcher |
26 | bus = dbus.SessionBus() |
27 | obj = bus.get_object('org.freedesktop.DBus', '/org/freedesktop/DBus') |
28 | |
29 | === modified file 'friends/protocols/twitter.py' |
30 | --- friends/protocols/twitter.py 2013-03-19 03:11:28 +0000 |
31 | +++ friends/protocols/twitter.py 2013-03-20 01:26:22 +0000 |
32 | @@ -26,7 +26,6 @@ |
33 | import logging |
34 | |
35 | from urllib.parse import quote |
36 | -from gi.repository import GLib |
37 | |
38 | from friends.utils.avatar import Avatar |
39 | from friends.utils.base import Base, feature |
40 | |
41 | === modified file 'friends/service/dispatcher.py' |
42 | --- friends/service/dispatcher.py 2013-03-14 13:29:53 +0000 |
43 | +++ friends/service/dispatcher.py 2013-03-20 01:26:22 +0000 |
44 | @@ -28,12 +28,13 @@ |
45 | import dbus.service |
46 | |
47 | from gi.repository import GLib |
48 | +from contextlib import ContextDecorator |
49 | |
50 | from friends.utils.avatar import Avatar |
51 | from friends.utils.account import AccountManager |
52 | from friends.utils.manager import protocol_manager |
53 | from friends.utils.menus import MenuManager |
54 | -from friends.utils.model import Model |
55 | +from friends.utils.model import Model, persist_model |
56 | from friends.shorteners import lookup |
57 | |
58 | |
59 | @@ -43,6 +44,48 @@ |
60 | STUB = lambda *ignore, **kwignore: None |
61 | |
62 | |
63 | +# Avoid race condition during shut-down |
64 | +_exit_lock = threading.Lock() |
65 | + |
66 | + |
67 | +class ManageTimers(ContextDecorator): |
68 | + """Exit the dispatcher 30s after the most recent method call returns.""" |
69 | + timers = set() |
70 | + callback = STUB |
71 | + |
72 | + def __enter__(self): |
73 | + self.clear_all_timers() |
74 | + |
75 | + def __exit__(self, *ignore): |
76 | + self.set_new_timer() |
77 | + |
78 | + def clear_all_timers(self): |
79 | + log.debug('Clearing {} shutdown timer(s)...'.format(len(self.timers))) |
80 | + while self.timers: |
81 | + GLib.source_remove(self.timers.pop()) |
82 | + |
83 | + def set_new_timer(self): |
84 | + # Concurrency will cause two methods to exit near each other, |
85 | + # causing two timers to be set, so we have to clear them again. |
86 | + self.clear_all_timers() |
87 | + log.debug('Starting new shutdown timer...') |
88 | + self.timers.add(GLib.timeout_add_seconds(30, self.terminate)) |
89 | + |
90 | + def terminate(self, *ignore): |
91 | + """Exit the dispatcher, but only if there are no active subthreads.""" |
92 | + with _exit_lock: |
93 | + if threading.activeCount() < 2: |
94 | + log.debug('No threads found, shutting down.') |
95 | + persist_model() |
96 | + self.timers.add(GLib.idle_add(self.callback)) |
97 | + else: |
98 | + log.debug('Delaying shutdown because active threads found.') |
99 | + self.set_new_timer() |
100 | + |
101 | + |
102 | +exit_after_idle = ManageTimers() |
103 | + |
104 | + |
105 | class Dispatcher(dbus.service.Object): |
106 | """This is the primary handler of dbus method calls.""" |
107 | __dbus_object_path__ = '/com/canonical/friends/Dispatcher' |
108 | @@ -59,10 +102,13 @@ |
109 | self.menu_manager = MenuManager(self.Refresh, self.mainloop.quit) |
110 | Model.connect('row-added', self._increment_unread_count) |
111 | |
112 | + ManageTimers.callback = mainloop.quit |
113 | + |
114 | def _increment_unread_count(self, model, itr): |
115 | self._unread_count += 1 |
116 | self.menu_manager.update_unread_count(self._unread_count) |
117 | |
118 | + @exit_after_idle |
119 | @dbus.service.method(DBUS_INTERFACE) |
120 | def Refresh(self): |
121 | """Download new messages from each connected protocol.""" |
122 | @@ -80,6 +126,7 @@ |
123 | # If a protocol doesn't support receive then ignore it. |
124 | pass |
125 | |
126 | + @exit_after_idle |
127 | @dbus.service.method(DBUS_INTERFACE) |
128 | def ClearIndicators(self): |
129 | """Indicate that messages have been read. |
130 | @@ -92,8 +139,8 @@ |
131 | service.ClearIndicators() |
132 | """ |
133 | self.menu_manager.update_unread_count(0) |
134 | - GLib.idle_add(self.mainloop.quit) |
135 | |
136 | + @exit_after_idle |
137 | @dbus.service.method(DBUS_INTERFACE, |
138 | in_signature='sss', |
139 | out_signature='s', |
140 | @@ -138,6 +185,7 @@ |
141 | if not called: |
142 | failure('No accounts supporting {} found.'.format(action)) |
143 | |
144 | + @exit_after_idle |
145 | @dbus.service.method(DBUS_INTERFACE, |
146 | in_signature='s', |
147 | out_signature='s', |
148 | @@ -172,6 +220,7 @@ |
149 | if not sent: |
150 | failure('No send_enabled accounts found.') |
151 | |
152 | + @exit_after_idle |
153 | @dbus.service.method(DBUS_INTERFACE, |
154 | in_signature='sss', |
155 | out_signature='s', |
156 | @@ -205,6 +254,7 @@ |
157 | failure(message) |
158 | log.error(message) |
159 | |
160 | + @exit_after_idle |
161 | @dbus.service.method(DBUS_INTERFACE, |
162 | in_signature='sss', |
163 | out_signature='s', |
164 | @@ -262,6 +312,7 @@ |
165 | failure(message) |
166 | log.error(message) |
167 | |
168 | + @exit_after_idle |
169 | @dbus.service.method(DBUS_INTERFACE, in_signature='s', out_signature='s') |
170 | def GetFeatures(self, protocol_name): |
171 | """Returns a list of features supported by service as json string. |
172 | @@ -274,9 +325,9 @@ |
173 | features = json.loads(service.GetFeatures('facebook')) |
174 | """ |
175 | protocol = protocol_manager.protocols.get(protocol_name) |
176 | - GLib.idle_add(self.mainloop.quit) |
177 | - return json.dumps(protocol.get_features()) |
178 | + return json.dumps(protocol.get_features() if protocol else []) |
179 | |
180 | + @exit_after_idle |
181 | @dbus.service.method(DBUS_INTERFACE, in_signature='s', out_signature='s') |
182 | def URLShorten(self, url): |
183 | """Shorten a URL. |
184 | @@ -291,7 +342,6 @@ |
185 | service = dbus.Interface(obj, DBUS_INTERFACE) |
186 | short_url = service.URLShorten(url) |
187 | """ |
188 | - GLib.idle_add(self.mainloop.quit) |
189 | service_name = self.settings.get_string('urlshorter') |
190 | log.info('Shortening URL {} with {}'.format(url, service_name)) |
191 | if (lookup.is_shortened(url) or |
192 | @@ -305,7 +355,7 @@ |
193 | log.exception('URL shortening class: {}'.format(service)) |
194 | return url |
195 | |
196 | + @exit_after_idle |
197 | @dbus.service.method(DBUS_INTERFACE) |
198 | def ExpireAvatars(self): |
199 | Avatar.expire_old_avatars() |
200 | - GLib.idle_add(self.mainloop.quit) |
201 | |
202 | === modified file 'friends/tests/test_dispatcher.py' |
203 | --- friends/tests/test_dispatcher.py 2013-03-13 04:36:33 +0000 |
204 | +++ friends/tests/test_dispatcher.py 2013-03-20 01:26:22 +0000 |
205 | @@ -26,7 +26,7 @@ |
206 | |
207 | from dbus.mainloop.glib import DBusGMainLoop |
208 | |
209 | -from friends.service.dispatcher import Dispatcher, STUB |
210 | +from friends.service.dispatcher import Dispatcher, ManageTimers, STUB |
211 | from friends.tests.mocks import LogMock, mock |
212 | |
213 | |
214 | @@ -61,7 +61,11 @@ |
215 | self.dispatcher.account_manager.get_all.assert_called_once_with() |
216 | account.protocol.assert_called_once_with('receive') |
217 | |
218 | - self.assertEqual(self.log_mock.empty(), 'Refresh requested\n') |
219 | + self.assertEqual(self.log_mock.empty(), |
220 | + 'Clearing 1 shutdown timer(s)...\n' |
221 | + 'Refresh requested\n' |
222 | + 'Clearing 0 shutdown timer(s)...\n' |
223 | + 'Starting new shutdown timer...\n') |
224 | |
225 | def test_clear_indicators(self): |
226 | self.dispatcher.menu_manager = mock.Mock() |
227 | @@ -81,7 +85,10 @@ |
228 | 'like', '23346356767354626', success=STUB, failure=STUB) |
229 | |
230 | self.assertEqual(self.log_mock.empty(), |
231 | - '345: like 23346356767354626\n') |
232 | + 'Clearing 1 shutdown timer(s)...\n' |
233 | + '345: like 23346356767354626\n' |
234 | + 'Clearing 0 shutdown timer(s)...\n' |
235 | + 'Starting new shutdown timer...\n') |
236 | |
237 | def test_failing_do(self): |
238 | account = mock.Mock() |
239 | @@ -93,7 +100,10 @@ |
240 | self.assertEqual(account.protocol.call_count, 0) |
241 | |
242 | self.assertEqual(self.log_mock.empty(), |
243 | - 'Could not find account: 6\n') |
244 | + 'Clearing 1 shutdown timer(s)...\n' |
245 | + 'Could not find account: 6\n' |
246 | + 'Clearing 0 shutdown timer(s)...\n' |
247 | + 'Starting new shutdown timer...\n') |
248 | |
249 | def test_send_message(self): |
250 | account1 = mock.Mock() |
251 | @@ -128,7 +138,10 @@ |
252 | success=STUB, failure=STUB) |
253 | |
254 | self.assertEqual(self.log_mock.empty(), |
255 | - 'Replying to 2, objid\n') |
256 | + 'Clearing 1 shutdown timer(s)...\n' |
257 | + 'Replying to 2, objid\n' |
258 | + 'Clearing 0 shutdown timer(s)...\n' |
259 | + 'Starting new shutdown timer...\n') |
260 | |
261 | def test_send_reply_failed(self): |
262 | account = mock.Mock() |
263 | @@ -140,8 +153,11 @@ |
264 | self.assertEqual(account.protocol.call_count, 0) |
265 | |
266 | self.assertEqual(self.log_mock.empty(), |
267 | - 'Replying to 2, objid\n' + |
268 | - 'Could not find account: 2\n') |
269 | + 'Clearing 1 shutdown timer(s)...\n' |
270 | + 'Replying to 2, objid\n' |
271 | + 'Could not find account: 2\n' |
272 | + 'Clearing 0 shutdown timer(s)...\n' |
273 | + 'Starting new shutdown timer...\n') |
274 | |
275 | def test_upload_async(self): |
276 | account = mock.Mock() |
277 | @@ -166,7 +182,10 @@ |
278 | ) |
279 | |
280 | self.assertEqual(self.log_mock.empty(), |
281 | - 'Uploading file://path/to/image.png to 2\n') |
282 | + 'Clearing 1 shutdown timer(s)...\n' |
283 | + 'Uploading file://path/to/image.png to 2\n' |
284 | + 'Clearing 0 shutdown timer(s)...\n' |
285 | + 'Starting new shutdown timer...\n') |
286 | |
287 | def test_get_features(self): |
288 | self.assertEqual(json.loads(self.dispatcher.GetFeatures('facebook')), |
289 | @@ -205,6 +224,52 @@ |
290 | self.dispatcher.URLShorten(long_url), |
291 | 'short url') |
292 | lookup_mock.is_shortened.assert_called_once_with(long_url) |
293 | - self.dispatcher.settings.get_boolean.assert_called_once_with('shorten-urls') |
294 | + self.dispatcher.settings.get_boolean.assert_called_once_with( |
295 | + 'shorten-urls') |
296 | lookup_mock.lookup.assert_called_once_with('is.gd') |
297 | - lookup_mock.lookup.return_value.shorten.assert_called_once_with(long_url) |
298 | + lookup_mock.lookup.return_value.shorten.assert_called_once_with( |
299 | + long_url) |
300 | + |
301 | + @mock.patch('friends.service.dispatcher.GLib') |
302 | + def test_manage_timers_clear(self, glib): |
303 | + manager = ManageTimers() |
304 | + manager.timers = {1} |
305 | + manager.__enter__() |
306 | + glib.source_remove.assert_called_once_with(1) |
307 | + manager.timers = {1, 2, 3} |
308 | + manager.clear_all_timers() |
309 | + self.assertEqual(glib.source_remove.call_count, 4) |
310 | + |
311 | + @mock.patch('friends.service.dispatcher.GLib') |
312 | + def test_manage_timers_set(self, glib): |
313 | + manager = ManageTimers() |
314 | + manager.timers = set() |
315 | + manager.clear_all_timers = mock.Mock() |
316 | + manager.__exit__() |
317 | + glib.timeout_add_seconds.assert_called_once_with(30, manager.terminate) |
318 | + manager.clear_all_timers.assert_called_once_with() |
319 | + self.assertEqual(len(manager.timers), 1) |
320 | + |
321 | + @mock.patch('friends.service.dispatcher.persist_model') |
322 | + @mock.patch('friends.service.dispatcher.threading') |
323 | + @mock.patch('friends.service.dispatcher.GLib') |
324 | + def test_manage_timers_terminate(self, glib, thread, persist): |
325 | + manager = ManageTimers() |
326 | + manager.timers = set() |
327 | + thread.activeCount.return_value = 1 |
328 | + manager.terminate() |
329 | + thread.activeCount.assert_called_once_with() |
330 | + persist.assert_called_once_with() |
331 | + glib.idle_add.assert_called_once_with(manager.callback) |
332 | + |
333 | + @mock.patch('friends.service.dispatcher.persist_model') |
334 | + @mock.patch('friends.service.dispatcher.threading') |
335 | + @mock.patch('friends.service.dispatcher.GLib') |
336 | + def test_manage_timers_dont_kill_threads(self, glib, thread, persist): |
337 | + manager = ManageTimers() |
338 | + manager.timers = set() |
339 | + manager.set_new_timer = mock.Mock() |
340 | + thread.activeCount.return_value = 10 |
341 | + manager.terminate() |
342 | + thread.activeCount.assert_called_once_with() |
343 | + manager.set_new_timer.assert_called_once_with() |
344 | |
345 | === modified file 'friends/utils/base.py' |
346 | --- friends/utils/base.py 2013-03-19 04:17:58 +0000 |
347 | +++ friends/utils/base.py 2013-03-20 01:26:22 +0000 |
348 | @@ -92,9 +92,6 @@ |
349 | # publishing new data into the SharedModel. |
350 | _publish_lock = threading.Lock() |
351 | |
352 | -# Avoid race condition during shut-down |
353 | -_exit_lock = threading.Lock() |
354 | - |
355 | |
356 | log = logging.getLogger(__name__) |
357 | |
358 | @@ -138,8 +135,6 @@ |
359 | |
360 | class _OperationThread(threading.Thread): |
361 | """Manage async callbacks, and log subthread exceptions.""" |
362 | - # main.py will replace this with a reference to the mainloop.quit method |
363 | - shutdown = lambda: log.error('Failed to exit friends-dispatcher main loop') |
364 | |
365 | def __init__(self, *args, id=None, success=STUB, failure=STUB, **kws): |
366 | self._id = id |
367 | @@ -171,13 +166,6 @@ |
368 | log.debug('{} has completed in {:.2f}s, thread exiting.'.format( |
369 | self._id, elapsed)) |
370 | |
371 | - # If this is the last thread to exit, then the refresh is |
372 | - # completed and we should save the model, and then exit. |
373 | - with _exit_lock: |
374 | - if threading.activeCount() < 3: |
375 | - persist_model() |
376 | - GLib.idle_add(self.shutdown) |
377 | - |
378 | |
379 | class Base: |
380 | """Parent class for any protocol plugin such as Facebook or Twitter. |