better thread handling in GUI and more resiliant start/stop, thanks P.Pastori

This commit is contained in:
mdipierro
2019-05-18 22:21:03 -07:00
parent 1790688357
commit 2ccd7a86ec
3 changed files with 176 additions and 144 deletions
+9 -1
View File
@@ -554,10 +554,14 @@ def wsgibase(environ, responder):
http_response, request, environ, ticket) http_response, request, environ, ticket)
if not http_response: if not http_response:
return wsgibase(new_environ, responder) return wsgibase(new_environ, responder)
if global_settings.web2py_crontype == 'soft': if global_settings.web2py_crontype == 'soft':
# FIXME: calling softcron crondance in a new thread at every
# request is not a good idea in a long running process
cmd_opts = global_settings.cmd_options cmd_opts = global_settings.cmd_options
newcron.softcron(global_settings.applications_parent, newcron.softcron(global_settings.applications_parent,
apps=cmd_opts and cmd_opts.crontabs).start() apps=cmd_opts and cmd_opts.crontabs).start()
return http_response.to(responder, env=env) return http_response.to(responder, env=env)
@@ -783,7 +787,11 @@ class HttpServer(object):
""" """
stop cron and the web server stop cron and the web server
""" """
newcron.stopcron() if global_settings.web2py_crontype == 'soft':
try:
newcron.stopcron()
except:
pass
self.server.stop(stoplogging) self.server.stop(stoplogging)
try: try:
os.unlink(self.pid_filename) os.unlink(self.pid_filename)
+14 -11
View File
@@ -12,25 +12,30 @@ Cron-style interface
import threading import threading
import os import os
from logging import getLogger, DEBUG from logging import getLogger
import time import time
import sched import sched
import sys import sys
import re import re
from functools import reduce
import datetime
import shlex import shlex
import datetime
from functools import reduce
from gluon.settings import global_settings
from gluon import fileutils from gluon import fileutils
from gluon._compat import to_bytes, pickle from gluon._compat import to_bytes, pickle
from pydal.contrib import portalocker from pydal.contrib import portalocker
from gluon.settings import global_settings
logger_name = 'web2py.cron' logger_name = 'web2py.cron'
_cron_stopping = False _cron_stopping = False
def reset():
global _cron_stopping
_cron_stopping = False
_cron_subprocs_lock = threading.RLock() _cron_subprocs_lock = threading.RLock()
_cron_subprocs = [] _cron_subprocs = []
@@ -75,9 +80,8 @@ class extcron(threading.Thread):
self.apps = apps self.apps = apps
def run(self): def run(self):
if not _cron_stopping: getLogger(logger_name).debug('external cron invocation')
getLogger(logger_name).debug('external cron invocation') crondance(self.path, 'external', startup=False, apps=self.apps)
crondance(self.path, 'external', startup=False, apps=self.apps)
class hardcron(threading.Thread): class hardcron(threading.Thread):
@@ -119,7 +123,6 @@ class softcron(threading.Thread):
crondance(self.path, 'soft', startup=False, apps=self.apps) crondance(self.path, 'soft', startup=False, apps=self.apps)
# TODO: context manager
class Token(object): class Token(object):
def __init__(self, path): def __init__(self, path):
@@ -140,7 +143,7 @@ class Token(object):
stop == 0 if job started but did not yet complete stop == 0 if job started but did not yet complete
if a cron job started within less than 60 seconds, acquire returns None if a cron job started within less than 60 seconds, acquire returns None
if a cron job started before 60 seconds and did not stop, if a cron job started before 60 seconds and did not stop,
a warning is issue "Stale cron.master detected" a warning is issued ("Stale cron.master detected")
""" """
if sys.platform == 'win32': if sys.platform == 'win32':
locktime = 59.5 locktime = 59.5
@@ -150,8 +153,8 @@ class Token(object):
self.logger.warning('cron disabled because no file locking') self.logger.warning('cron disabled because no file locking')
return None return None
self.master = fileutils.open_file(self.path, 'rb+') self.master = fileutils.open_file(self.path, 'rb+')
ret = None
try: try:
ret = None
portalocker.lock(self.master, portalocker.LOCK_EX) portalocker.lock(self.master, portalocker.LOCK_EX)
try: try:
(start, stop) = pickle.load(self.master) (start, stop) = pickle.load(self.master)
@@ -278,7 +281,7 @@ class cronlauncher(threading.Thread):
if proc.returncode != 0: if proc.returncode != 0:
logger.warning('%r call returned code %s:\n%s\n%s', logger.warning('%r call returned code %s:\n%s\n%s',
' '.join(self.cmd), proc.returncode, stdoutdata, stderrdata) ' '.join(self.cmd), proc.returncode, stdoutdata, stderrdata)
elif logger.isEnabledFor(DEBUG): else:
logger.debug('%r call returned success:\n%s', logger.debug('%r call returned success:\n%s',
' '.join(self.cmd), stdoutdata) ' '.join(self.cmd), stdoutdata)
+153 -132
View File
@@ -1,4 +1,5 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# vim: set ts=4 sw=4 et ai:
""" """
| This file is part of the web2py Web Framework | This file is part of the web2py Web Framework
@@ -11,23 +12,26 @@ GUI widget and services start function
from __future__ import print_function from __future__ import print_function
import sys
from gluon._compat import thread, xrange, PY2
import time import time
import threading import sys
import os import os
from collections import OrderedDict
import socket import socket
import signal import threading
import math import math
import logging import logging
import signal
import getpass import getpass
from gluon import main, newcron
from gluon.fileutils import read_file, create_welcome_w2p from gluon.fileutils import read_file, create_welcome_w2p
from gluon.console import console
from gluon.settings import global_settings
from gluon.shell import die, run, test from gluon.shell import die, run, test
from gluon.utils import is_valid_ip_address, is_loopback_ip_address, getipaddrinfo from gluon._compat import PY2, xrange
from gluon.utils import (getipaddrinfo, is_loopback_ip_address,
is_valid_ip_address)
from gluon.console import is_appdir, console
from gluon import newcron
from gluon import main
from gluon.settings import global_settings
ProgramName = 'web2py Web Framework' ProgramName = 'web2py Web Framework'
@@ -119,24 +123,27 @@ class web2pyDialog(object):
import tkinter import tkinter
from tkinter import messagebox from tkinter import messagebox
bg_color = 'white'
root.withdraw() root.withdraw()
bg_color = 'white'
self.root = tkinter.Toplevel(root, bg=bg_color) self.root = tkinter.Toplevel(root, bg=bg_color)
self.root.resizable(0, 0) self.root.resizable(0, 0)
self.root.title(ProgramName) self.root.title(ProgramName)
self.options = options self.options = options
self.scheduler_processes = {} self.scheduler_processes_lock = threading.RLock()
self.menu = tkinter.Menu(self.root) self.scheduler_processes = OrderedDict()
servermenu = tkinter.Menu(self.menu, tearoff=0)
httplog = os.path.join(self.options.folder, self.options.log_filename)
iconphoto = os.path.join('extras', 'icons', 'web2py.gif') iconphoto = os.path.join('extras', 'icons', 'web2py.gif')
if os.path.exists(iconphoto): if os.path.exists(iconphoto):
img = tkinter.PhotoImage(file=iconphoto) img = tkinter.PhotoImage(file=iconphoto)
self.root.tk.call('wm', 'iconphoto', self.root._w, img) self.root.tk.call('wm', 'iconphoto', self.root._w, img)
# Building the Menu # Building the Menu
self.menu = tkinter.Menu(self.root)
servermenu = tkinter.Menu(self.menu, tearoff=0)
httplog = os.path.join(options.folder, options.log_filename)
item = lambda: start_browser(httplog) item = lambda: start_browser(httplog)
servermenu.add_command(label='View httpserver.log', servermenu.add_command(label='View httpserver.log',
command=item) command=item)
@@ -149,10 +156,9 @@ class web2pyDialog(object):
self.pagesmenu = tkinter.Menu(self.menu, tearoff=0) self.pagesmenu = tkinter.Menu(self.menu, tearoff=0)
self.menu.add_cascade(label='Pages', menu=self.pagesmenu) self.menu.add_cascade(label='Pages', menu=self.pagesmenu)
#scheduler menu
self.schedmenu = tkinter.Menu(self.menu, tearoff=0) self.schedmenu = tkinter.Menu(self.menu, tearoff=0)
self.menu.add_cascade(label='Scheduler', menu=self.schedmenu) self.menu.add_cascade(label='Scheduler', menu=self.schedmenu)
#start and register schedulers from options # register and start schedulers
self.update_schedulers(start=True) self.update_schedulers(start=True)
helpmenu = tkinter.Menu(self.menu, tearoff=0) helpmenu = tkinter.Menu(self.menu, tearoff=0)
@@ -257,7 +263,7 @@ class web2pyDialog(object):
sticky=sticky) sticky=sticky)
self.port_number = tkinter.Entry(self.root) self.port_number = tkinter.Entry(self.root)
self.port_number.insert(tkinter.END, self.options.port) self.port_number.insert(tkinter.END, options.port)
self.port_number.grid(row=shift, column=2, sticky=sticky, pady=10) self.port_number.grid(row=shift, column=2, sticky=sticky, pady=10)
# Password # Password
@@ -314,6 +320,26 @@ class web2pyDialog(object):
self.tb = None self.tb = None
def update_schedulers(self, start=False): def update_schedulers(self, start=False):
applications_folder = os.path.join(self.options.folder, 'applications')
available_apps = [
arq for arq in os.listdir(applications_folder)
if os.path.isdir(os.path.join(applications_folder, arq))
]
with self.scheduler_processes_lock:
# reset the menu
# since applications can disappear (be disinstalled) must
# clear the menu (should use tkinter.END or tkinter.LAST)
self.schedmenu.delete(0, 'end')
for arq in available_apps:
if arq not in self.scheduler_processes:
item = lambda a = arq: self.try_start_scheduler(a)
self.schedmenu.add_command(label="start %s" % arq,
command=item)
if arq in self.scheduler_processes:
item = lambda a = arq: self.try_stop_scheduler(a)
self.schedmenu.add_command(label="stop %s" % arq,
command=item)
if start and self.options.with_scheduler and self.options.schedulers: if start and self.options.with_scheduler and self.options.schedulers:
# the widget takes care of starting the schedulers # the widget takes care of starting the schedulers
apps = [ag.split(':', 1)[0] for ag in self.options.schedulers] apps = [ag.split(':', 1)[0] for ag in self.options.schedulers]
@@ -322,100 +348,90 @@ class web2pyDialog(object):
for app in apps: for app in apps:
self.try_start_scheduler(app) self.try_start_scheduler(app)
# reset the menu
applications_folder = os.path.join(self.options.folder, 'applications')
available_apps = [
arq for arq in os.listdir(applications_folder)
if os.path.isdir(os.path.join(applications_folder, arq))
]
self.schedmenu.delete(0, len(available_apps))
for arq in available_apps:
if arq not in self.scheduler_processes:
item = lambda u = arq: self.try_start_scheduler(u)
self.schedmenu.add_command(label="start %s" % arq,
command=item)
if arq in self.scheduler_processes:
item = lambda u = arq: self.try_stop_scheduler(u)
self.schedmenu.add_command(label="stop %s" % arq,
command=item)
def start_schedulers(self, app): def start_schedulers(self, app):
try: from multiprocessing import Process
from multiprocessing import Process
except:
sys.stderr.write('Sorry, -K only supported for Python 2.6+\n')
return
code = "from gluon.globals import current;current._scheduler.loop()" code = "from gluon.globals import current;current._scheduler.loop()"
print('starting scheduler from widget for "%s"...' % app) print('starting scheduler from widget for "%s"...' % app)
args = (app, True, True, None, False, code, False, True) args = (app, True, True, None, False, code, False, True)
logging.getLogger().setLevel(self.options.log_level)
p = Process(target=run, args=args) p = Process(target=run, args=args)
self.scheduler_processes[app] = p with self.scheduler_processes_lock:
self.update_schedulers() self.scheduler_processes[app] = p
print("Currently running %s scheduler processes" % ( self.update_schedulers()
len(self.scheduler_processes))) print("Currently running %s scheduler processes" % (
len(self.scheduler_processes)))
p.start() p.start()
print("Processes started") print("Processes started")
def try_stop_scheduler(self, app): def try_stop_scheduler(self, app, skip_update=False):
if app in self.scheduler_processes: p = None
p = self.scheduler_processes[app] with self.scheduler_processes_lock:
del self.scheduler_processes[app] if app in self.scheduler_processes:
p = self.scheduler_processes[app]
del self.scheduler_processes[app]
if p is not None:
p.terminate() p.terminate()
p.join() p.join()
self.update_schedulers() if not skip_update:
self.update_schedulers()
def try_start_scheduler(self, app): def try_start_scheduler(self, app):
if app not in self.scheduler_processes: t = None
t = threading.Thread(target=self.start_schedulers, args=(app,)) with self.scheduler_processes_lock:
if not is_appdir(self.options.folder, app):
self.schedmenu.delete("start %s" % app)
return
if app not in self.scheduler_processes:
t = threading.Thread(target=self.start_schedulers, args=(app,))
if t is not None:
t.start() t.start()
def checkTaskBar(self): def checkTaskBar(self):
""" Checks taskbar status """ """ Checks taskbar status """
tb = self.tb
if self.tb.status: if tb.status:
if self.tb.status[0] == self.tb.EnumStatus.QUIT: st0 = tb.status[0]
EnumStatus = tb.EnumStatus
if st0 == EnumStatus.QUIT:
self.quit() self.quit()
elif self.tb.status[0] == self.tb.EnumStatus.TOGGLE: elif st0 == EnumStatus.TOGGLE:
if self.root.state() == 'withdrawn': if self.root.state() == 'withdrawn':
self.root.deiconify() self.root.deiconify()
else: else:
self.root.withdraw() self.root.withdraw()
elif self.tb.status[0] == self.tb.EnumStatus.STOP: elif st0 == EnumStatus.STOP:
self.stop() self.stop()
elif self.tb.status[0] == self.tb.EnumStatus.START: elif st0 == EnumStatus.START:
self.start() self.start()
elif self.tb.status[0] == self.tb.EnumStatus.RESTART: elif st0 == EnumStatus.RESTART:
self.stop() self.stop()
self.start() self.start()
del self.tb.status[0] del tb.status[0]
self.root.after(1000, self.checkTaskBar) self.root.after(1000, self.checkTaskBar)
def update(self, text):
""" Updates app text """
try:
self.text.configure(state='normal')
self.text.insert('end', text)
self.text.configure(state='disabled')
except:
pass # ## this should only happen in case app is destroyed
def connect_pages(self): def connect_pages(self):
""" Connects pages """ """ Connects pages """
# reset the menu # reset the menu,
# since applications can disappear (be disinstalled) must
# clear the menu (should use tkinter.END or tkinter.LAST)
self.pagesmenu.delete(0, 'end')
applications_folder = os.path.join(self.options.folder, 'applications') applications_folder = os.path.join(self.options.folder, 'applications')
available_apps = [ available_apps = [
arq for arq in os.listdir(applications_folder) arq for arq in os.listdir(applications_folder)
if os.path.exists(os.path.join(applications_folder, arq, '__init__.py')) if os.path.exists(os.path.join(applications_folder, arq, '__init__.py'))
] ]
self.pagesmenu.delete(0, len(available_apps))
for arq in available_apps: for arq in available_apps:
url = self.url + arq url = self.url + arq
item = lambda a = arq: self.try_start_browser(a)
self.pagesmenu.add_command( self.pagesmenu.add_command(
label=url, command=lambda u=url: start_browser(u)) label=url, command=item)
def try_start_browser(self, app):
url = self.url + app
if not is_appdir(self.options.folder, app):
self.pagesmenu.delete(url)
return
start_browser(url)
def quit(self, justHide=False): def quit(self, justHide=False):
""" Finishes the program execution """ """ Finishes the program execution """
@@ -423,16 +439,20 @@ class web2pyDialog(object):
self.root.withdraw() self.root.withdraw()
else: else:
try: try:
scheds = self.scheduler_processes.keys() with self.scheduler_processes_lock:
scheds = list(self.scheduler_processes.keys())
for t in scheds: for t in scheds:
self.try_stop_scheduler(t) self.try_stop_scheduler(t, skip_update=True)
except:
pass
try:
newcron.stopcron()
except: except:
pass pass
if self.options.with_cron and not self.options.soft_cron:
# shutting down hardcron
try:
newcron.stopcron()
except:
pass
try: try:
# HttpServer.stop takes care of stopping softcron
self.server.stop() self.server.stop()
except: except:
pass pass
@@ -450,34 +470,40 @@ class web2pyDialog(object):
import tkMessageBox as messagebox import tkMessageBox as messagebox
else: else:
from tkinter import messagebox from tkinter import messagebox
messagebox.showerror('web2py start server', message) messagebox.showerror('web2py start server', message)
def start(self): def start(self):
""" Starts web2py server """ """ Starts web2py server """
password = self.password.get() password = self.password.get()
if not password: if not password:
self.error('no password, no web admin interface') self.error('no password, no web admin interface')
ip = self.selected_ip.get() ip = self.selected_ip.get()
if not is_valid_ip_address(ip): if not is_valid_ip_address(ip):
return self.error('invalid host ip address') return self.error('invalid host ip address')
try: try:
port = int(self.port_number.get()) port = int(self.port_number.get())
except: except ValueError:
return self.error('invalid port number') return self.error('invalid port number')
if self.options.server_key and self.options.server_cert: if self.options.server_key and self.options.server_cert:
proto = 'https' proto = 'https'
else: else:
proto = 'http' proto = 'http'
self.url = get_url(ip, proto=proto, port=port) self.url = get_url(ip, proto=proto, port=port)
self.connect_pages() self.connect_pages()
self.update_schedulers()
# softcron is stopped with HttpServer, thus if starting again
# need to reset newcron._cron_stopping to re-enable cron
if self.options.soft_cron:
newcron.reset()
# FIXME: if the HttpServer is stopped, then started again,
# does not start because of following error:
# WARNING:Rocket.Errors.Port8000:Listener started when not ready.
self.button_start.configure(state='disabled') self.button_start.configure(state='disabled')
try: try:
@@ -502,7 +528,7 @@ class web2pyDialog(object):
path=options.folder, path=options.folder,
interfaces=options.interfaces) interfaces=options.interfaces)
thread.start_new_thread(self.server.start, ()) threading.Thread(target=self.server.start).start()
except Exception as e: except Exception as e:
self.button_start.configure(state='normal') self.button_start.configure(state='normal')
return self.error(str(e)) return self.error(str(e))
@@ -514,11 +540,14 @@ class web2pyDialog(object):
self.button_stop.configure(state='normal') self.button_stop.configure(state='normal')
if not options.taskbar: if not options.taskbar:
thread.start_new_thread( cpt = threading.Thread(target=start_browser,
start_browser, (get_url(ip, proto=proto, port=port), True)) args=(get_url(ip, proto=proto, port=port), True))
cpt.setDaemon(True)
cpt.start()
self.password.configure(state='readonly') self.password.configure(state='readonly')
[ip.configure(state='disabled') for ip in self.ips.values()] for ip in self.ips.values():
ip.configure(state='disabled')
self.port_number.configure(state='readonly') self.port_number.configure(state='readonly')
if self.tb: if self.tb:
@@ -528,16 +557,15 @@ class web2pyDialog(object):
for listener in self.server.server.listeners: for listener in self.server.server.listeners:
if listener.ready: if listener.ready:
return True return True
return False return False
def stop(self): def stop(self):
""" Stops web2py server """ """ Stops web2py server """
self.button_start.configure(state='normal') self.button_start.configure(state='normal')
self.button_stop.configure(state='disabled') self.button_stop.configure(state='disabled')
self.password.configure(state='normal') self.password.configure(state='normal')
[ip.configure(state='normal') for ip in self.ips.values()] for ip in self.ips.values():
ip.configure(state='normal')
self.port_number.configure(state='normal') self.port_number.configure(state='normal')
self.server.stop() self.server.stop()
@@ -546,48 +574,41 @@ class web2pyDialog(object):
def update_canvas(self): def update_canvas(self):
""" Updates canvas """ """ Updates canvas """
httplog = os.path.join(self.options.folder, self.options.log_filename) httplog = os.path.join(self.options.folder, self.options.log_filename)
canvas = self.canvas
try: try:
t1 = os.path.getsize(httplog) t1 = os.path.getsize(httplog)
except: except OSError:
self.canvas.after(1000, self.update_canvas) canvas.after(1000, self.update_canvas)
return return
points = 400
try: try:
fp = open(httplog, 'r') pvalues = self.p0[1:]
fp.seek(self.t0) with open(httplog, 'r') as fp:
data = fp.read(t1 - self.t0) fp.seek(self.t0)
fp.close() data = fp.read(t1 - self.t0)
value = self.p0[1:] + [10 + 90.0 / math.sqrt(1 + data.count('\n'))] self.p0 = pvalues + [10 + 90.0 / math.sqrt(1 + data.count('\n'))]
self.p0 = value
for i in xrange(len(self.p0) - 1): for i in xrange(points - 1):
c = self.canvas.coords(self.q0[i]) c = canvas.coords(self.q0[i])
self.canvas.coords(self.q0[i], canvas.coords(self.q0[i],
(c[0], (c[0], self.p0[i],
self.p0[i], c[2], self.p0[i + 1]))
c[2],
self.p0[i + 1]))
self.t0 = t1 self.t0 = t1
except BaseException: except AttributeError:
self.t0 = time.time() self.t0 = time.time()
self.t0 = t1 self.t0 = t1
self.p0 = [100] * 400 self.p0 = [100] * points
self.q0 = [self.canvas.create_line(i, 100, i + 1, 100, self.q0 = [canvas.create_line(i, 100, i + 1, 100,
fill='green') for i in xrange(len(self.p0) - 1)] fill='green') for i in xrange(points - 1)]
self.canvas.after(1000, self.update_canvas) canvas.after(1000, self.update_canvas)
def check_existent_app(options, appname): def get_code_for_scheduler(applications_parent, app_groups):
if os.path.isdir(os.path.join(options.folder, 'applications', appname)):
return True
def get_code_for_scheduler(app_groups, options):
app = app_groups[0] app = app_groups[0]
if not check_existent_app(options, app): if not is_appdir(applications_parent, app):
print("Application '%s' doesn't exist, skipping" % app) print("Application '%s' doesn't exist, skipping" % app)
return None, None return None, None
code = 'from gluon.globals import current;' code = 'from gluon.globals import current;'
@@ -599,16 +620,10 @@ def get_code_for_scheduler(app_groups, options):
def start_schedulers(options): def start_schedulers(options):
try: from multiprocessing import Process
from multiprocessing import Process
except:
sys.stderr.write('Sorry, -K only supported for Python 2.6+\n')
return
logging.getLogger().setLevel(options.log_level)
apps = [ag.split(':') for ag in options.schedulers] apps = [ag.split(':') for ag in options.schedulers]
if not options.with_scheduler and len(apps) == 1: if not options.with_scheduler and len(apps) == 1:
app, code = get_code_for_scheduler(apps[0], options) app, code = get_code_for_scheduler(options.folder, apps[0])
if not app: if not app:
return return
print('starting single-scheduler for "%s"...' % app) print('starting single-scheduler for "%s"...' % app)
@@ -624,7 +639,7 @@ def start_schedulers(options):
processes = [] processes = []
for app_groups in apps: for app_groups in apps:
app, code = get_code_for_scheduler(app_groups, options) app, code = get_code_for_scheduler(options.folder, app_groups)
if not app: if not app:
continue continue
print('starting scheduler for "%s"...' % app) print('starting scheduler for "%s"...' % app)
@@ -652,6 +667,12 @@ def start():
# get command line arguments # get command line arguments
options = console(version=ProgramVersion) options = console(version=ProgramVersion)
if options.with_scheduler or len(options.schedulers) > 1:
try:
from multiprocessing import Process
except:
die('Sorry, -K/--scheduler only supported for Python 2.6+')
if options.gae: if options.gae:
# write app.yaml, gaehandler.py, and exit # write app.yaml, gaehandler.py, and exit
if not os.path.exists('app.yaml'): if not os.path.exists('app.yaml'):
@@ -673,6 +694,7 @@ def start():
logger = logging.getLogger("web2py") logger = logging.getLogger("web2py")
logger.setLevel(options.log_level) logger.setLevel(options.log_level)
logging.getLogger().setLevel(options.log_level) # root logger
# on new installation build the scaffolding app # on new installation build the scaffolding app
create_welcome_w2p() create_welcome_w2p()
@@ -807,7 +829,7 @@ end tell
options.password = getpass.getpass('choose a password:') options.password = getpass.getpass('choose a password:')
if not options.password and not options.no_banner: if not options.password and not options.no_banner:
print('no password, disable admin interface') print('no password, no web admin interface')
# Use first interface IP and port if interfaces specified, since the # Use first interface IP and port if interfaces specified, since the
# interfaces option overrides the IP (and related) options. # interfaces option overrides the IP (and related) options.
@@ -883,5 +905,4 @@ end tell
spt.join() spt.join()
except: except:
logger.exception('error terminating schedulers') logger.exception('error terminating schedulers')
pass
logging.shutdown() logging.shutdown()