wip
[hippotat] / hippotat / __init__.py
CommitLineData
b0cfbfce
IJ
1# -*- python -*-
2
37ab4cdc
IJ
3import signal
4signal.signal(signal.SIGINT, signal.SIG_DFL)
5
1321ad5f 6import sys
cae50358 7import os
1321ad5f 8
b83d422a
IJ
9from zope.interface import implementer
10
040ff511
IJ
11import twisted
12from twisted.internet import reactor
1d023c89 13import twisted.internet.endpoints
8c3b6620
IJ
14import twisted.logger
15from twisted.logger import LogLevel
16import twisted.python.constants
17from twisted.python.constants import NamedConstant
b0cfbfce
IJ
18
19import ipaddress
20from ipaddress import AddressValueError
21
ae7c7784 22from optparse import OptionParser
5510890e 23import configparser
ae7c7784
IJ
24from configparser import ConfigParser
25from configparser import NoOptionError
26
c13ee6e6
IJ
27from functools import partial
28
ae7c7784 29import collections
84e763c7 30import time
8c3b6620 31import codecs
eedc8b30 32import traceback
ae7c7784 33
1321ad5f
IJ
34import re as regexp
35
36import hippotat.slip as slip
37
d579a048 38class DBG(twisted.python.constants.Names):
380ed56c 39 INIT = NamedConstant()
cae50358 40 CONFIG = NamedConstant()
d579a048 41 ROUTE = NamedConstant()
b68c0739 42 DROP = NamedConstant()
d579a048
IJ
43 FLOW = NamedConstant()
44 HTTP = NamedConstant()
380ed56c 45 TWISTED = NamedConstant()
d579a048 46 QUEUE = NamedConstant()
380ed56c 47 HTTP_CTRL = NamedConstant()
d579a048 48 QUEUE_CTRL = NamedConstant()
297b3ebf 49 HTTP_FULL = NamedConstant()
0accf0d3 50 CTRL_DUMP = NamedConstant()
380ed56c 51 SLIP_FULL = NamedConstant()
9acb0eca 52 DATA_COMPLETE = NamedConstant()
d579a048 53
b68c0739 54_hex_codec = codecs.getencoder('hex_codec')
8c3b6620 55
b83d422a
IJ
56#---------- logging ----------
57
58org_stderr = sys.stderr
59
8c3b6620
IJ
60log = twisted.logger.Logger()
61
2e68eb10
IJ
62debug_set = set()
63debug_def_detail = DBG.HTTP
3e35fc99 64
8c3b6620 65def log_debug(dflag, msg, idof=None, d=None):
3e35fc99 66 if dflag not in debug_set: return
e8fcf3b7 67 #print('---------------->',repr((dflag, msg, idof, d)), file=sys.stderr)
8c3b6620 68 if idof is not None:
e8ed0029 69 msg = '[%#x] %s' % (id(idof), msg)
8c3b6620 70 if d is not None:
9acb0eca
IJ
71 trunc = ''
72 if not DBG.DATA_COMPLETE in debug_set:
73 if len(d) > 64:
74 d = d[0:64]
75 trunc = '...'
b68c0739 76 d = _hex_codec(d)[0].decode('ascii')
9acb0eca 77 msg += ' ' + d + trunc
8c3b6620
IJ
78 log.info('{dflag} {msgcore}', dflag=dflag, msgcore=msg)
79
b83d422a
IJ
80@implementer(twisted.logger.ILogFilterPredicate)
81class LogNotBoringTwisted:
82 def __call__(self, event):
83 yes = twisted.logger.PredicateResult.yes
84 no = twisted.logger.PredicateResult.no
85 try:
86 if event.get('log_level') != LogLevel.info:
87 return yes
9acb0eca 88 dflag = event.get('dflag')
c7f134ce 89 if dflag is False : return yes
9acb0eca
IJ
90 if dflag in debug_set: return yes
91 if dflag is None and DBG.TWISTED in debug_set: return yes
92 return no
b83d422a
IJ
93 except Exception:
94 print(traceback.format_exc(), file=org_stderr)
95 return yes
96
97#---------- default config ----------
98
ca732796
IJ
99defcfg = '''
100[DEFAULT]
9e445690
IJ
101max_batch_down = 65536
102max_queue_time = 10
103target_requests_outstanding = 3
104http_timeout = 30
105http_timeout_grace = 5
106max_requests_outstanding = 6
107max_batch_up = 4000
108http_retry = 5
c7fb640e 109port = 80
8d374606 110vroutes = ''
ca732796
IJ
111
112#[server] or [<client>] overrides
113ipif = userv root ipif %(local)s,%(peer)s,%(mtu)s,slip %(rnets)s
ca732796 114
9e445690 115# relating to virtual network
ca732796 116mtu = 1500
ca732796 117
c7fb640e
IJ
118[SERVER]
119server = SERVER
9e445690 120# addrs = 127.0.0.1 ::1
9e445690
IJ
121# url
122
123# relating to virtual network
8d374606
IJ
124vvnetwork = 172.24.230.192
125# vnetwork = <prefix>/<len>
126# vadd r = <ipaddr>
127# vrelay = <ipaddr>
9e445690 128
ca732796
IJ
129
130# [<client-ip4-or-ipv6-address>]
131# password = <password> # used by both, must match
132
c7fb640e 133[LIMIT]
9e445690
IJ
134max_batch_down = 262144
135max_queue_time = 121
136http_timeout = 121
137target_requests_outstanding = 10
ca732796
IJ
138'''
139
87a7c0c7 140# these need to be defined here so that they can be imported by import *
cae50358 141cfg = ConfigParser(strict=False)
ae7c7784
IJ
142optparser = OptionParser()
143
e4006ac4 144_mimetrans = bytes.maketrans(b'-'+slip.esc, slip.esc+b'-')
7b07f0b5
IJ
145def mime_translate(s):
146 # SLIP-encoded packets cannot contain ESC ESC.
147 # Swap `-' and ESC. The result cannot contain `--'
148 return s.translate(_mimetrans)
149
87a7c0c7 150class ConfigResults:
c7fb640e
IJ
151 def __init__(self):
152 pass
87a7c0c7
IJ
153 def __repr__(self):
154 return 'ConfigResults('+repr(self.__dict__)+')'
155
a8827d59 156def log_discard(packet, iface, saddr, daddr, why):
b68c0739 157 log_debug(DBG.DROP,
a8827d59 158 'discarded packet [%s] %s -> %s: %s' % (iface, saddr, daddr, why),
b68c0739 159 d=packet)
1321ad5f 160
b0cfbfce
IJ
161#---------- packet parsing ----------
162
163def packet_addrs(packet):
164 version = packet[0] >> 4
165 if version == 4:
166 addrlen = 4
167 saddroff = 3*4
168 factory = ipaddress.IPv4Address
169 elif version == 6:
170 addrlen = 16
171 saddroff = 2*4
172 factory = ipaddress.IPv6Address
173 else:
174 raise ValueError('unsupported IP version %d' % version)
175 saddr = factory(packet[ saddroff : saddroff + addrlen ])
176 daddr = factory(packet[ saddroff + addrlen : saddroff + addrlen*2 ])
177 return (saddr, daddr)
178
179#---------- address handling ----------
180
181def ipaddr(input):
182 try:
183 r = ipaddress.IPv4Address(input)
184 except AddressValueError:
185 r = ipaddress.IPv6Address(input)
186 return r
187
188def ipnetwork(input):
189 try:
190 r = ipaddress.IPv4Network(input)
191 except NetworkValueError:
192 r = ipaddress.IPv6Network(input)
193 return r
040ff511
IJ
194
195#---------- ipif (SLIP) subprocess ----------
196
a95cfeb2 197class SlipStreamDecoder():
db6ba584 198 def __init__(self, desc, on_packet):
040ff511 199 self._buffer = b''
a95cfeb2 200 self._on_packet = on_packet
db6ba584
IJ
201 self._desc = desc
202 self._log('__init__')
203
204 def _log(self, msg, **kwargs):
3297cac1 205 log_debug(DBG.SLIP_FULL, 'slip %s: %s' % (self._desc, msg), **kwargs)
a95cfeb2
IJ
206
207 def inputdata(self, data):
db6ba584 208 self._log('inputdata', d=data)
7a68893f 209 packets = slip.decode(data)
ba5630fd 210 packets[0] = self._buffer + packets[0]
040ff511
IJ
211 self._buffer = packets.pop()
212 for packet in packets:
a95cfeb2 213 self._maybe_packet(packet)
54890d4d 214 self._log('bufremain', d=self._buffer)
a95cfeb2
IJ
215
216 def _maybe_packet(self, packet):
54890d4d 217 self._log('maybepacket', d=packet)
db6ba584
IJ
218 if len(packet):
219 self._on_packet(packet)
a95cfeb2 220
4f991c0c 221 def flush(self):
54890d4d 222 self._log('flush')
a95cfeb2
IJ
223 self._maybe_packet(self._buffer)
224 self._buffer = b''
4f991c0c 225
e4006ac4 226class _IpifProcessProtocol(twisted.internet.protocol.ProcessProtocol):
4f991c0c
IJ
227 def __init__(self, router):
228 self._router = router
db6ba584 229 self._decoder = SlipStreamDecoder('ipif', self.slip_on_packet)
a95cfeb2
IJ
230 def connectionMade(self): pass
231 def outReceived(self, data):
232 self._decoder.inputdata(data)
233 def slip_on_packet(self, packet):
4f991c0c
IJ
234 (saddr, daddr) = packet_addrs(packet)
235 if saddr.is_link_local or daddr.is_link_local:
a8827d59 236 log_discard(packet, 'ipif', saddr, daddr, 'link-local')
4f991c0c
IJ
237 return
238 self._router(packet, saddr, daddr)
040ff511
IJ
239 def processEnded(self, status):
240 status.raiseException()
241
242def start_ipif(command, router):
040ff511
IJ
243 ipif = _IpifProcessProtocol(router)
244 reactor.spawnProcess(ipif,
245 '/bin/sh',['sh','-xc', command],
ff613365
IJ
246 childFDs={0:'w', 1:'r', 2:2},
247 env=None)
909e0ff3 248 return ipif
040ff511 249
909e0ff3 250def queue_inbound(ipif, packet):
15407d80 251 log_debug(DBG.FLOW, "queue_inbound", d=packet)
040ff511
IJ
252 ipif.transport.write(slip.delimiter)
253 ipif.transport.write(slip.encode(packet))
254 ipif.transport.write(slip.delimiter)
255
650a3251
IJ
256#---------- packet queue ----------
257
258class PacketQueue():
d579a048
IJ
259 def __init__(self, desc, max_queue_time):
260 self._desc = desc
8718b02c 261 assert(desc + '')
650a3251
IJ
262 self._max_queue_time = max_queue_time
263 self._pq = collections.deque() # packets
264
b68c0739 265 def _log(self, dflag, msg, **kwargs):
8c3b6620 266 log_debug(dflag, self._desc+' pq: '+msg, **kwargs)
d579a048 267
650a3251 268 def append(self, packet):
8c3b6620 269 self._log(DBG.QUEUE, 'append', d=packet)
650a3251
IJ
270 self._pq.append((time.monotonic(), packet))
271
272 def nonempty(self):
8c3b6620 273 self._log(DBG.QUEUE, 'nonempty ?')
650a3251
IJ
274 while True:
275 try: (queuetime, packet) = self._pq[0]
8c3b6620
IJ
276 except IndexError:
277 self._log(DBG.QUEUE, 'nonempty ? empty.')
278 return False
650a3251
IJ
279
280 age = time.monotonic() - queuetime
84e763c7 281 if age > self._max_queue_time:
650a3251 282 # strip old packets off the front
8c3b6620 283 self._log(DBG.QUEUE, 'dropping (old)', d=packet)
650a3251
IJ
284 self._pq.popleft()
285 continue
286
8c3b6620 287 self._log(DBG.QUEUE, 'nonempty ? nonempty.')
650a3251
IJ
288 return True
289
7b07f0b5
IJ
290 def process(self, sizequery, moredata, max_batch):
291 # sizequery() should return size of batch so far
292 # moredata(s) should add s to batch
8c3b6620 293 self._log(DBG.QUEUE, 'process...')
7b07f0b5
IJ
294 while True:
295 try: (dummy, packet) = self._pq[0]
8c3b6620
IJ
296 except IndexError:
297 self._log(DBG.QUEUE, 'process... empty')
298 break
299
300 self._log(DBG.QUEUE_CTRL, 'process... packet', d=packet)
7b07f0b5
IJ
301
302 encoded = slip.encode(packet)
303 sofar = sizequery()
304
8c3b6620
IJ
305 self._log(DBG.QUEUE_CTRL,
306 'process... (sofar=%d, max=%d) encoded' % (sofar, max_batch),
b68c0739 307 d=encoded)
8c3b6620 308
7b07f0b5
IJ
309 if sofar > 0:
310 if sofar + len(slip.delimiter) + len(encoded) > max_batch:
8c3b6620 311 self._log(DBG.QUEUE_CTRL, 'process... overflow')
7b07f0b5
IJ
312 break
313 moredata(slip.delimiter)
314
315 moredata(encoded)
84e763c7 316 self._pq.popleft()
ae7c7784
IJ
317
318#---------- error handling ----------
319
b68c0739
IJ
320_crashing = False
321
ae7c7784 322def crash(err):
b68c0739
IJ
323 global _crashing
324 _crashing = True
e8ed0029
IJ
325 print('========== CRASH ==========', err,
326 '===========================', file=sys.stderr)
ae7c7784
IJ
327 try: reactor.stop()
328 except twisted.internet.error.ReactorNotRunning: pass
329
330def crash_on_defer(defer):
331 defer.addErrback(lambda err: crash(err))
332
e4006ac4 333def crash_on_critical(event):
ae7c7784
IJ
334 if event.get('log_level') >= LogLevel.critical:
335 crash(twisted.logger.formatEvent(event))
336
87a7c0c7
IJ
337#---------- config processing ----------
338
c7fb640e
IJ
339def _cfg_process_putatives():
340 servers = { }
341 clients = { }
342 # maps from abstract object to canonical name for cs's
87a7c0c7 343
c7fb640e
IJ
344 def putative(cmap, abstract, canoncs):
345 try:
346 current_canoncs = cmap[abstract]
347 except KeyError:
348 pass
349 else:
350 assert(current_canoncs == canoncs)
351 cmap[abstract] = canoncs
352
353 server_pat = r'[-.0-9A-Za-z]+'
354 client_pat = r'[.:0-9a-f]+'
355 server_re = regexp.compile(server_pat)
356 serverclient_re = regexp.compile(server_pat + r' ' + client_pat)
88487243 357
c7fb640e 358 for cs in cfg.sections():
8d374606 359 if cs == 'LIMIT':
c7fb640e
IJ
360 # plan A "[LIMIT]"
361 continue
88487243 362
c7fb640e
IJ
363 try:
364 # plan B "[<client>]" part 1
365 ci = ipaddr(cs)
366 except AddressValueError:
88487243 367
c7fb640e
IJ
368 if server_re.fullmatch(cs):
369 # plan C "[<servername>]"
370 putative(servers, cs, cs)
371 continue
372
373 if serverclient_re.fullmatch(cs):
374 # plan D "[<servername> <client>]" part 1
375 (pss,pcs) = cs.split(' ')
376
8d374606 377 if pcs == 'LIMIT':
c7fb640e
IJ
378 # plan E "[<servername> LIMIT]"
379 continue
380
381 try:
382 # plan D "[<servername> <client>]" part 2
383 ci = ipaddr(pc)
384 except AddressValueError:
385 # plan F "[<some thing we do not understand>]"
386 # well, we ignore this
387 print('warning: ignoring config section %s' % cs, file=sys.stderr)
388 continue
389
390 else: # no AddressValueError
391 # plan D "[<servername> <client]" part 3
392 putative(clients, ci, pcs)
393 putative(servers, pss, pss)
394 continue
395
396 else: # no AddressValueError
397 # plan B "[<client>" part 2
398 putative(clients, ci, cs)
399 continue
400
401 return (servers, clients)
402
74934d63 403def cfg_process_common(c, ss):
c7fb640e
IJ
404 c.mtu = cfg.getint(ss, 'mtu')
405
406def cfg_process_saddrs(c, ss):
407 class ServerAddr():
408 def __init__(self, port, addrspec):
409 self.port = port
410 # also self.addr
411 try:
412 self.addr = ipaddress.IPv4Address(addrspec)
413 self._endpointfactory = twisted.internet.endpoints.TCP4ServerEndpoint
414 self._inurl = b'%s'
415 except AddressValueError:
416 self.addr = ipaddress.IPv6Address(addrspec)
417 self._endpointfactory = twisted.internet.endpoints.TCP6ServerEndpoint
418 self._inurl = b'[%s]'
419 def make_endpoint(self):
420 return self._endpointfactory(reactor, self.port, self.addr)
421 def url(self):
422 url = b'http://' + (self._inurl % str(self.addr).encode('ascii'))
423 if self.port != 80: url += b':%d' % self.port
424 url += b'/'
425 return url
426
427 c.port = cfg.getint(ss,'port')
428 c.saddrs = [ ]
429 for addrspec in cfg.get(ss, 'addrs').split():
430 sa = ServerAddr(c.port, addrspec)
431 c.saddrs.append(sa)
432
433def cfg_process_vnetwork(c, ss):
c7f134ce
IJ
434 c.vnetwork = ipnetwork(cfg.get(ss,'vnetwork'))
435 if c.vnetwork.num_addresses < 3 + 2:
436 raise ValueError('vnetwork needs at least 2^3 addresses')
88487243 437
8d374606 438def cfg_process_vaddr(c, ss):
88487243 439 try:
c7f134ce 440 c.vaddr = cfg.get(ss,'vaddr')
88487243 441 except NoOptionError:
8d374606 442 cfg_process_vnetwork(c, ss)
c7f134ce 443 c.vaddr = next(c.vnetwork.hosts())
88487243 444
c7fb640e
IJ
445def cfg_search_section(key,sections):
446 for section in sections:
447 if cfg.has_option(section, key):
448 return section
8d374606 449 raise NoOptionError(key, repr(sections))
c7fb640e
IJ
450
451def cfg_search(getter,key,sections):
452 section = cfg_search_section(key,sections)
453 return getter(section, key)
454
455def cfg_process_client_limited(cc,ss,sections,key):
456 val = cfg_search(cfg.getint, key, sections)
8d374606 457 lim = cfg_search(cfg.getint, key, ['%s LIMIT' % ss, 'LIMIT'])
c7fb640e
IJ
458 cc.__dict__[key] = min(val,lim)
459
460def cfg_process_client_common(cc,ss,cs,ci):
461 # returns sections to search in, iff password is defined, otherwise None
462 cc.ci = ci
463
8d374606 464 sections = ['%s %s' % (ss,cs),
c7fb640e
IJ
465 cs,
466 ss,
467 'DEFAULT']
468
469 try: pwsection = cfg_search_section('password', sections)
470 except NoOptionError: return None
88487243 471
c7fb640e 472 pw = cfg.get(pwsection, 'password')
c7f134ce 473 cc.password = pw.encode('utf-8')
88487243 474
c7fb640e
IJ
475 cfg_process_client_limited(cc,ss,sections,'target_requests_outstanding')
476 cfg_process_client_limited(cc,ss,sections,'http_timeout')
88487243 477
c7fb640e
IJ
478 return sections
479
8d374606 480def cfg_process_ipif(c, sections, varmap):
c7fb640e
IJ
481 for d, s in varmap:
482 try: v = getattr(c, s)
483 except AttributeError: continue
484 setattr(c, d, v)
485
c7f134ce 486 #print('CFGIPIF',repr((varmap, sections, c.__dict__)),file=sys.stderr)
8d374606 487
c7fb640e
IJ
488 section = cfg_search_section('ipif', sections)
489 c.ipif_command = cfg.get(section,'ipif', vars=c.__dict__)
88487243 490
ae7c7784
IJ
491#---------- startup ----------
492
5510890e 493def common_startup(process_cfg):
c7fb640e
IJ
494 # calls process_cfg(putative_clients, putative_servers)
495
82302bac 496 # ConfigParser hates #-comments after values
c7fb640e 497 trailingcomments_re = regexp.compile(r'#.*')
82302bac 498 cfg.read_string(trailingcomments_re.sub('', defcfg))
cae50358
IJ
499 need_defcfg = True
500
501 def readconfig(pathname, mandatory=True):
502 def log(m, p=pathname):
503 if not DBG.CONFIG in debug_set: return
504 print('DBG.CONFIG: %s: %s' % (m, pathname))
505
506 try:
507 files = os.listdir(pathname)
508
509 except FileNotFoundError:
510 if mandatory: raise
511 log('skipped')
512 return
513
514 except NotADirectoryError:
515 cfg.read(pathname)
516 log('read file')
517 return
518
519 # is a directory
520 log('directory')
521 re = regexp.compile('[^-A-Za-z0-9_]')
522 for f in os.listdir(cdir):
523 if re.search(f): continue
524 subpath = pathname + '/' + f
525 try:
526 os.stat(subpath)
527 except FileNotFoundError:
528 log('entry skipped', subpath)
529 continue
530 cfg.read(subpath)
531 log('entry read', subpath)
532
533 def oc_config(od,os, value, op):
534 nonlocal need_defcfg
535 need_defcfg = False
536 readconfig(value)
2e68eb10 537
9acb0eca
IJ
538 def dfs_less_detailed(dl):
539 return [df for df in DBG.iterconstants() if df <= dl]
540
541 def ds_default(od,os,dl,op):
2e68eb10 542 global debug_set
9acb0eca 543 debug_set = set(dfs_less_detailed(debug_def_detail))
2e68eb10 544
9acb0eca 545 def ds_select(od,os, spec, op):
9acb0eca
IJ
546 for it in spec.split(','):
547
9acb0eca
IJ
548 if it.startswith('-'):
549 mutator = debug_set.discard
550 it = it[1:]
551 else:
552 mutator = debug_set.add
2cf75145
IJ
553
554 if it == '+':
555 dfs = DBG.iterconstants()
556
557 else:
558 if it.endswith('+'):
559 mapper = dfs_less_detailed
560 it = it[0:len(it)-1]
561 else:
562 mapper = lambda x: [x]
563
564 try:
565 dfspec = DBG.lookupByName(it)
566 except ValueError:
567 optparser.error('unknown debug flag %s in --debug-select' % it)
568
569 dfs = mapper(dfspec)
570
571 for df in dfs:
572 mutator(df)
9acb0eca
IJ
573
574 optparser.add_option('-D', '--debug',
2e68eb10
IJ
575 nargs=0,
576 action='callback',
9acb0eca
IJ
577 help='enable default debug (to stdout)',
578 callback= ds_default)
579
580 optparser.add_option('--debug-select',
581 nargs=1,
582 type='string',
2cf75145 583 metavar='[-]DFLAG[+]|[-]+,...',
9acb0eca 584 help=
2cf75145
IJ
585'''enable (`-': disable) each specified DFLAG;
586`+': do same for all "more interesting" DFLAGSs;
587just `+': all DFLAGs.
588 DFLAGS: ''' + ' '.join([df.name for df in DBG.iterconstants()]),
9acb0eca
IJ
589 action='callback',
590 callback= ds_select)
2e68eb10 591
cae50358
IJ
592 optparser.add_option('-c', '--config',
593 nargs=1,
594 type='string',
595 metavar='CONFIGFILE',
596 dest='configfile',
597 action='callback',
598 callback= oc_config)
599
2e68eb10
IJ
600 (opts, args) = optparser.parse_args()
601 if len(args): optparser.error('no non-option arguments please')
602
cae50358
IJ
603 if need_defcfg:
604 readconfig('/etc/hippotat/config', False)
605 readconfig('/etc/hippotat/config.d', False)
9acb0eca 606
c7fb640e 607 try:
8d374606 608 (pss, pcs) = _cfg_process_putatives()
c7fb640e 609 process_cfg(pss, pcs)
5510890e
IJ
610 except (configparser.Error, ValueError):
611 traceback.print_exc(file=sys.stderr)
612 print('\nInvalid configuration, giving up.', file=sys.stderr)
613 sys.exit(12)
614
cae50358 615 #print(repr(debug_set), file=sys.stderr)
2e68eb10 616
8c3b6620 617 log_formatter = twisted.logger.formatEventAsClassicLogText
389236df
IJ
618 stdout_obs = twisted.logger.FileLogObserver(sys.stdout, log_formatter)
619 stderr_obs = twisted.logger.FileLogObserver(sys.stderr, log_formatter)
620 pred = twisted.logger.LogLevelFilterPredicate(LogLevel.error)
b83d422a 621 stdsomething_obs = twisted.logger.FilteringLogObserver(
389236df
IJ
622 stderr_obs, [pred], stdout_obs
623 )
b83d422a
IJ
624 log_observer = twisted.logger.FilteringLogObserver(
625 stdsomething_obs, [LogNotBoringTwisted()]
626 )
627 #log_observer = stdsomething_obs
8c3b6620
IJ
628 twisted.logger.globalLogBeginner.beginLoggingTo(
629 [ log_observer, crash_on_critical ]
630 )
ae7c7784 631
ae7c7784 632def common_run():
b68c0739
IJ
633 log_debug(DBG.INIT, 'entering reactor')
634 if not _crashing: reactor.run()
ae7c7784 635 print('CRASHED (end)', file=sys.stderr)