##// END OF EJS Templates
chgserver: add a structure for confighash and mtimehash...
Jun Wu -
r28277:cdc6319f default
parent child Browse files
Show More
@@ -1,583 +1,599 b''
1 1 # chgserver.py - command server extension for cHg
2 2 #
3 3 # Copyright 2011 Yuya Nishihara <yuya@tcha.org>
4 4 #
5 5 # This software may be used and distributed according to the terms of the
6 6 # GNU General Public License version 2 or any later version.
7 7
8 8 """command server extension for cHg (EXPERIMENTAL)
9 9
10 10 'S' channel (read/write)
11 11 propagate ui.system() request to client
12 12
13 13 'attachio' command
14 14 attach client's stdio passed by sendmsg()
15 15
16 16 'chdir' command
17 17 change current directory
18 18
19 19 'getpager' command
20 20 checks if pager is enabled and which pager should be executed
21 21
22 22 'setenv' command
23 23 replace os.environ completely
24 24
25 25 'SIGHUP' signal
26 26 reload configuration files
27 27 """
28 28
29 29 from __future__ import absolute_import
30 30
31 31 import SocketServer
32 32 import errno
33 33 import inspect
34 34 import os
35 35 import re
36 36 import signal
37 37 import struct
38 38 import sys
39 39 import threading
40 40 import time
41 41 import traceback
42 42
43 43 from mercurial.i18n import _
44 44
45 45 from mercurial import (
46 46 cmdutil,
47 47 commands,
48 48 commandserver,
49 49 dispatch,
50 50 error,
51 51 extensions,
52 52 osutil,
53 53 util,
54 54 )
55 55
56 56 # Note for extension authors: ONLY specify testedwith = 'internal' for
57 57 # extensions which SHIP WITH MERCURIAL. Non-mainline extensions should
58 58 # be specifying the version(s) of Mercurial they are tested with, or
59 59 # leave the attribute unspecified.
60 60 testedwith = 'internal'
61 61
62 62 _log = commandserver.log
63 63
64 64 def _hashlist(items):
65 65 """return sha1 hexdigest for a list"""
66 66 return util.sha1(str(items)).hexdigest()
67 67
68 68 # sensitive config sections affecting confighash
69 69 _configsections = ['extensions']
70 70
71 71 # sensitive environment variables affecting confighash
72 72 _envre = re.compile(r'''\A(?:
73 73 CHGHG
74 74 |HG.*
75 75 |LANG(?:UAGE)?
76 76 |LC_.*
77 77 |LD_.*
78 78 |PATH
79 79 |PYTHON.*
80 80 |TERM(?:INFO)?
81 81 |TZ
82 82 )\Z''', re.X)
83 83
84 84 def _confighash(ui):
85 85 """return a quick hash for detecting config/env changes
86 86
87 87 confighash is the hash of sensitive config items and environment variables.
88 88
89 89 for chgserver, it is designed that once confighash changes, the server is
90 90 not qualified to serve its client and should redirect the client to a new
91 91 server. different from mtimehash, confighash change will not mark the
92 92 server outdated and exit since the user can have different configs at the
93 93 same time.
94 94 """
95 95 sectionitems = []
96 96 for section in _configsections:
97 97 sectionitems.append(ui.configitems(section))
98 98 sectionhash = _hashlist(sectionitems)
99 99 envitems = [(k, v) for k, v in os.environ.iteritems() if _envre.match(k)]
100 100 envhash = _hashlist(sorted(envitems))
101 101 return sectionhash[:6] + envhash[:6]
102 102
103 103 def _getmtimepaths(ui):
104 104 """get a list of paths that should be checked to detect change
105 105
106 106 The list will include:
107 107 - extensions (will not cover all files for complex extensions)
108 108 - mercurial/__version__.py
109 109 - python binary
110 110 """
111 111 modules = [m for n, m in extensions.extensions(ui)]
112 112 try:
113 113 from mercurial import __version__
114 114 modules.append(__version__)
115 115 except ImportError:
116 116 pass
117 117 files = [sys.executable]
118 118 for m in modules:
119 119 try:
120 120 files.append(inspect.getabsfile(m))
121 121 except TypeError:
122 122 pass
123 123 return sorted(set(files))
124 124
125 125 def _mtimehash(paths):
126 126 """return a quick hash for detecting file changes
127 127
128 128 mtimehash calls stat on given paths and calculate a hash based on size and
129 129 mtime of each file. mtimehash does not read file content because reading is
130 130 expensive. therefore it's not 100% reliable for detecting content changes.
131 131 it's possible to return different hashes for same file contents.
132 132 it's also possible to return a same hash for different file contents for
133 133 some carefully crafted situation.
134 134
135 135 for chgserver, it is designed that once mtimehash changes, the server is
136 136 considered outdated immediately and should no longer provide service.
137 137 """
138 138 def trystat(path):
139 139 try:
140 140 st = os.stat(path)
141 141 return (st.st_mtime, st.st_size)
142 142 except OSError:
143 143 # could be ENOENT, EPERM etc. not fatal in any case
144 144 pass
145 145 return _hashlist(map(trystat, paths))[:12]
146 146
147 class hashstate(object):
148 """a structure storing confighash, mtimehash, paths used for mtimehash"""
149 def __init__(self, confighash, mtimehash, mtimepaths):
150 self.confighash = confighash
151 self.mtimehash = mtimehash
152 self.mtimepaths = mtimepaths
153
154 @staticmethod
155 def fromui(ui, mtimepaths=None):
156 if mtimepaths is None:
157 mtimepaths = _getmtimepaths(ui)
158 confighash = _confighash(ui)
159 mtimehash = _mtimehash(mtimepaths)
160 _log('confighash = %s mtimehash = %s\n' % (confighash, mtimehash))
161 return hashstate(confighash, mtimehash, mtimepaths)
162
147 163 # copied from hgext/pager.py:uisetup()
148 164 def _setuppagercmd(ui, options, cmd):
149 165 if not ui.formatted():
150 166 return
151 167
152 168 p = ui.config("pager", "pager", os.environ.get("PAGER"))
153 169 usepager = False
154 170 always = util.parsebool(options['pager'])
155 171 auto = options['pager'] == 'auto'
156 172
157 173 if not p:
158 174 pass
159 175 elif always:
160 176 usepager = True
161 177 elif not auto:
162 178 usepager = False
163 179 else:
164 180 attended = ['annotate', 'cat', 'diff', 'export', 'glog', 'log', 'qdiff']
165 181 attend = ui.configlist('pager', 'attend', attended)
166 182 ignore = ui.configlist('pager', 'ignore')
167 183 cmds, _ = cmdutil.findcmd(cmd, commands.table)
168 184
169 185 for cmd in cmds:
170 186 var = 'attend-%s' % cmd
171 187 if ui.config('pager', var):
172 188 usepager = ui.configbool('pager', var)
173 189 break
174 190 if (cmd in attend or
175 191 (cmd not in ignore and not attend)):
176 192 usepager = True
177 193 break
178 194
179 195 if usepager:
180 196 ui.setconfig('ui', 'formatted', ui.formatted(), 'pager')
181 197 ui.setconfig('ui', 'interactive', False, 'pager')
182 198 return p
183 199
184 200 _envvarre = re.compile(r'\$[a-zA-Z_]+')
185 201
186 202 def _clearenvaliases(cmdtable):
187 203 """Remove stale command aliases referencing env vars; variable expansion
188 204 is done at dispatch.addaliases()"""
189 205 for name, tab in cmdtable.items():
190 206 cmddef = tab[0]
191 207 if (isinstance(cmddef, dispatch.cmdalias) and
192 208 not cmddef.definition.startswith('!') and # shell alias
193 209 _envvarre.search(cmddef.definition)):
194 210 del cmdtable[name]
195 211
196 212 def _newchgui(srcui, csystem):
197 213 class chgui(srcui.__class__):
198 214 def __init__(self, src=None):
199 215 super(chgui, self).__init__(src)
200 216 if src:
201 217 self._csystem = getattr(src, '_csystem', csystem)
202 218 else:
203 219 self._csystem = csystem
204 220
205 221 def system(self, cmd, environ=None, cwd=None, onerr=None,
206 222 errprefix=None):
207 223 # copied from mercurial/util.py:system()
208 224 self.flush()
209 225 def py2shell(val):
210 226 if val is None or val is False:
211 227 return '0'
212 228 if val is True:
213 229 return '1'
214 230 return str(val)
215 231 env = os.environ.copy()
216 232 if environ:
217 233 env.update((k, py2shell(v)) for k, v in environ.iteritems())
218 234 env['HG'] = util.hgexecutable()
219 235 rc = self._csystem(cmd, env, cwd)
220 236 if rc and onerr:
221 237 errmsg = '%s %s' % (os.path.basename(cmd.split(None, 1)[0]),
222 238 util.explainexit(rc)[0])
223 239 if errprefix:
224 240 errmsg = '%s: %s' % (errprefix, errmsg)
225 241 raise onerr(errmsg)
226 242 return rc
227 243
228 244 return chgui(srcui)
229 245
230 246 def _renewui(srcui, args=None):
231 247 if not args:
232 248 args = []
233 249
234 250 newui = srcui.__class__()
235 251 for a in ['fin', 'fout', 'ferr', 'environ']:
236 252 setattr(newui, a, getattr(srcui, a))
237 253 if util.safehasattr(srcui, '_csystem'):
238 254 newui._csystem = srcui._csystem
239 255
240 256 # load wd and repo config, copied from dispatch.py
241 257 cwds = dispatch._earlygetopt(['--cwd'], args)
242 258 cwd = cwds and os.path.realpath(cwds[-1]) or None
243 259 rpath = dispatch._earlygetopt(["-R", "--repository", "--repo"], args)
244 260 path, newui = dispatch._getlocal(newui, rpath, wd=cwd)
245 261
246 262 # internal config: extensions.chgserver
247 263 # copy it. it can only be overrided from command line.
248 264 newui.setconfig('extensions', 'chgserver',
249 265 srcui.config('extensions', 'chgserver'), '--config')
250 266
251 267 # command line args
252 268 dispatch._parseconfig(newui, dispatch._earlygetopt(['--config'], args))
253 269
254 270 # stolen from tortoisehg.util.copydynamicconfig()
255 271 for section, name, value in srcui.walkconfig():
256 272 source = srcui.configsource(section, name)
257 273 if ':' in source or source == '--config':
258 274 # path:line or command line
259 275 continue
260 276 if source == 'none':
261 277 # ui.configsource returns 'none' by default
262 278 source = ''
263 279 newui.setconfig(section, name, value, source)
264 280 return newui
265 281
266 282 class channeledsystem(object):
267 283 """Propagate ui.system() request in the following format:
268 284
269 285 payload length (unsigned int),
270 286 cmd, '\0',
271 287 cwd, '\0',
272 288 envkey, '=', val, '\0',
273 289 ...
274 290 envkey, '=', val
275 291
276 292 and waits:
277 293
278 294 exitcode length (unsigned int),
279 295 exitcode (int)
280 296 """
281 297 def __init__(self, in_, out, channel):
282 298 self.in_ = in_
283 299 self.out = out
284 300 self.channel = channel
285 301
286 302 def __call__(self, cmd, environ, cwd):
287 303 args = [util.quotecommand(cmd), cwd or '.']
288 304 args.extend('%s=%s' % (k, v) for k, v in environ.iteritems())
289 305 data = '\0'.join(args)
290 306 self.out.write(struct.pack('>cI', self.channel, len(data)))
291 307 self.out.write(data)
292 308 self.out.flush()
293 309
294 310 length = self.in_.read(4)
295 311 length, = struct.unpack('>I', length)
296 312 if length != 4:
297 313 raise error.Abort(_('invalid response'))
298 314 rc, = struct.unpack('>i', self.in_.read(4))
299 315 return rc
300 316
301 317 _iochannels = [
302 318 # server.ch, ui.fp, mode
303 319 ('cin', 'fin', 'rb'),
304 320 ('cout', 'fout', 'wb'),
305 321 ('cerr', 'ferr', 'wb'),
306 322 ]
307 323
308 324 class chgcmdserver(commandserver.server):
309 325 def __init__(self, ui, repo, fin, fout, sock):
310 326 super(chgcmdserver, self).__init__(
311 327 _newchgui(ui, channeledsystem(fin, fout, 'S')), repo, fin, fout)
312 328 self.clientsock = sock
313 329 self._oldios = [] # original (self.ch, ui.fp, fd) before "attachio"
314 330
315 331 def cleanup(self):
316 332 # dispatch._runcatch() does not flush outputs if exception is not
317 333 # handled by dispatch._dispatch()
318 334 self.ui.flush()
319 335 self._restoreio()
320 336
321 337 def attachio(self):
322 338 """Attach to client's stdio passed via unix domain socket; all
323 339 channels except cresult will no longer be used
324 340 """
325 341 # tell client to sendmsg() with 1-byte payload, which makes it
326 342 # distinctive from "attachio\n" command consumed by client.read()
327 343 self.clientsock.sendall(struct.pack('>cI', 'I', 1))
328 344 clientfds = osutil.recvfds(self.clientsock.fileno())
329 345 _log('received fds: %r\n' % clientfds)
330 346
331 347 ui = self.ui
332 348 ui.flush()
333 349 first = self._saveio()
334 350 for fd, (cn, fn, mode) in zip(clientfds, _iochannels):
335 351 assert fd > 0
336 352 fp = getattr(ui, fn)
337 353 os.dup2(fd, fp.fileno())
338 354 os.close(fd)
339 355 if not first:
340 356 continue
341 357 # reset buffering mode when client is first attached. as we want
342 358 # to see output immediately on pager, the mode stays unchanged
343 359 # when client re-attached. ferr is unchanged because it should
344 360 # be unbuffered no matter if it is a tty or not.
345 361 if fn == 'ferr':
346 362 newfp = fp
347 363 else:
348 364 # make it line buffered explicitly because the default is
349 365 # decided on first write(), where fout could be a pager.
350 366 if fp.isatty():
351 367 bufsize = 1 # line buffered
352 368 else:
353 369 bufsize = -1 # system default
354 370 newfp = os.fdopen(fp.fileno(), mode, bufsize)
355 371 setattr(ui, fn, newfp)
356 372 setattr(self, cn, newfp)
357 373
358 374 self.cresult.write(struct.pack('>i', len(clientfds)))
359 375
360 376 def _saveio(self):
361 377 if self._oldios:
362 378 return False
363 379 ui = self.ui
364 380 for cn, fn, _mode in _iochannels:
365 381 ch = getattr(self, cn)
366 382 fp = getattr(ui, fn)
367 383 fd = os.dup(fp.fileno())
368 384 self._oldios.append((ch, fp, fd))
369 385 return True
370 386
371 387 def _restoreio(self):
372 388 ui = self.ui
373 389 for (ch, fp, fd), (cn, fn, _mode) in zip(self._oldios, _iochannels):
374 390 newfp = getattr(ui, fn)
375 391 # close newfp while it's associated with client; otherwise it
376 392 # would be closed when newfp is deleted
377 393 if newfp is not fp:
378 394 newfp.close()
379 395 # restore original fd: fp is open again
380 396 os.dup2(fd, fp.fileno())
381 397 os.close(fd)
382 398 setattr(self, cn, ch)
383 399 setattr(ui, fn, fp)
384 400 del self._oldios[:]
385 401
386 402 def chdir(self):
387 403 """Change current directory
388 404
389 405 Note that the behavior of --cwd option is bit different from this.
390 406 It does not affect --config parameter.
391 407 """
392 408 path = self._readstr()
393 409 if not path:
394 410 return
395 411 _log('chdir to %r\n' % path)
396 412 os.chdir(path)
397 413
398 414 def setumask(self):
399 415 """Change umask"""
400 416 mask = struct.unpack('>I', self._read(4))[0]
401 417 _log('setumask %r\n' % mask)
402 418 os.umask(mask)
403 419
404 420 def getpager(self):
405 421 """Read cmdargs and write pager command to r-channel if enabled
406 422
407 423 If pager isn't enabled, this writes '\0' because channeledoutput
408 424 does not allow to write empty data.
409 425 """
410 426 args = self._readlist()
411 427 try:
412 428 cmd, _func, args, options, _cmdoptions = dispatch._parse(self.ui,
413 429 args)
414 430 except (error.Abort, error.AmbiguousCommand, error.CommandError,
415 431 error.UnknownCommand):
416 432 cmd = None
417 433 options = {}
418 434 if not cmd or 'pager' not in options:
419 435 self.cresult.write('\0')
420 436 return
421 437
422 438 pagercmd = _setuppagercmd(self.ui, options, cmd)
423 439 if pagercmd:
424 440 self.cresult.write(pagercmd)
425 441 else:
426 442 self.cresult.write('\0')
427 443
428 444 def setenv(self):
429 445 """Clear and update os.environ
430 446
431 447 Note that not all variables can make an effect on the running process.
432 448 """
433 449 l = self._readlist()
434 450 try:
435 451 newenv = dict(s.split('=', 1) for s in l)
436 452 except ValueError:
437 453 raise ValueError('unexpected value in setenv request')
438 454
439 455 diffkeys = set(k for k in set(os.environ.keys() + newenv.keys())
440 456 if os.environ.get(k) != newenv.get(k))
441 457 _log('change env: %r\n' % sorted(diffkeys))
442 458
443 459 os.environ.clear()
444 460 os.environ.update(newenv)
445 461
446 462 if set(['HGPLAIN', 'HGPLAINEXCEPT']) & diffkeys:
447 463 # reload config so that ui.plain() takes effect
448 464 self.ui = _renewui(self.ui)
449 465
450 466 _clearenvaliases(commands.table)
451 467
452 468 capabilities = commandserver.server.capabilities.copy()
453 469 capabilities.update({'attachio': attachio,
454 470 'chdir': chdir,
455 471 'getpager': getpager,
456 472 'setenv': setenv,
457 473 'setumask': setumask})
458 474
459 475 # copied from mercurial/commandserver.py
460 476 class _requesthandler(SocketServer.StreamRequestHandler):
461 477 def handle(self):
462 478 # use a different process group from the master process, making this
463 479 # process pass kernel "is_current_pgrp_orphaned" check so signals like
464 480 # SIGTSTP, SIGTTIN, SIGTTOU are not ignored.
465 481 os.setpgid(0, 0)
466 482 ui = self.server.ui
467 483 repo = self.server.repo
468 484 sv = chgcmdserver(ui, repo, self.rfile, self.wfile, self.connection)
469 485 try:
470 486 try:
471 487 sv.serve()
472 488 # handle exceptions that may be raised by command server. most of
473 489 # known exceptions are caught by dispatch.
474 490 except error.Abort as inst:
475 491 ui.warn(_('abort: %s\n') % inst)
476 492 except IOError as inst:
477 493 if inst.errno != errno.EPIPE:
478 494 raise
479 495 except KeyboardInterrupt:
480 496 pass
481 497 finally:
482 498 sv.cleanup()
483 499 except: # re-raises
484 500 # also write traceback to error channel. otherwise client cannot
485 501 # see it because it is written to server's stderr by default.
486 502 traceback.print_exc(file=sv.cerr)
487 503 raise
488 504
489 505 def _tempaddress(address):
490 506 return '%s.%d.tmp' % (address, os.getpid())
491 507
492 508 class AutoExitMixIn: # use old-style to comply with SocketServer design
493 509 lastactive = time.time()
494 510 idletimeout = 3600 # default 1 hour
495 511
496 512 def startautoexitthread(self):
497 513 # note: the auto-exit check here is cheap enough to not use a thread,
498 514 # be done in serve_forever. however SocketServer is hook-unfriendly,
499 515 # you simply cannot hook serve_forever without copying a lot of code.
500 516 # besides, serve_forever's docstring suggests using thread.
501 517 thread = threading.Thread(target=self._autoexitloop)
502 518 thread.daemon = True
503 519 thread.start()
504 520
505 521 def _autoexitloop(self, interval=1):
506 522 while True:
507 523 time.sleep(interval)
508 524 if not self.issocketowner():
509 525 _log('%s is not owned, exiting.\n' % self.server_address)
510 526 break
511 527 if time.time() - self.lastactive > self.idletimeout:
512 528 _log('being idle too long. exiting.\n')
513 529 break
514 530 self.shutdown()
515 531
516 532 def process_request(self, request, address):
517 533 self.lastactive = time.time()
518 534 return SocketServer.ForkingMixIn.process_request(
519 535 self, request, address)
520 536
521 537 def server_bind(self):
522 538 # use a unique temp address so we can stat the file and do ownership
523 539 # check later
524 540 tempaddress = _tempaddress(self.server_address)
525 541 self.socket.bind(tempaddress)
526 542 self._socketstat = os.stat(tempaddress)
527 543 # rename will replace the old socket file if exists atomically. the
528 544 # old server will detect ownership change and exit.
529 545 util.rename(tempaddress, self.server_address)
530 546
531 547 def issocketowner(self):
532 548 try:
533 549 stat = os.stat(self.server_address)
534 550 return (stat.st_ino == self._socketstat.st_ino and
535 551 stat.st_mtime == self._socketstat.st_mtime)
536 552 except OSError:
537 553 return False
538 554
539 555 def unlinksocketfile(self):
540 556 if not self.issocketowner():
541 557 return
542 558 # it is possible to have a race condition here that we may
543 559 # remove another server's socket file. but that's okay
544 560 # since that server will detect and exit automatically and
545 561 # the client will start a new server on demand.
546 562 try:
547 563 os.unlink(self.server_address)
548 564 except OSError as exc:
549 565 if exc.errno != errno.ENOENT:
550 566 raise
551 567
552 568 class chgunixservice(commandserver.unixservice):
553 569 def init(self):
554 570 # drop options set for "hg serve --cmdserver" command
555 571 self.ui.setconfig('progress', 'assume-tty', None)
556 572 signal.signal(signal.SIGHUP, self._reloadconfig)
557 573 class cls(AutoExitMixIn, SocketServer.ForkingMixIn,
558 574 SocketServer.UnixStreamServer):
559 575 ui = self.ui
560 576 repo = self.repo
561 577 self.server = cls(self.address, _requesthandler)
562 578 self.server.idletimeout = self.ui.configint(
563 579 'chgserver', 'idletimeout', self.server.idletimeout)
564 580 self.server.startautoexitthread()
565 581 # avoid writing "listening at" message to stdout before attachio
566 582 # request, which calls setvbuf()
567 583
568 584 def _reloadconfig(self, signum, frame):
569 585 self.ui = self.server.ui = _renewui(self.ui)
570 586
571 587 def run(self):
572 588 try:
573 589 self.server.serve_forever()
574 590 finally:
575 591 self.server.unlinksocketfile()
576 592
577 593 def uisetup(ui):
578 594 commandserver._servicemap['chgunix'] = chgunixservice
579 595
580 596 # CHGINTERNALMARK is temporarily set by chg client to detect if chg will
581 597 # start another chg. drop it to avoid possible side effects.
582 598 if 'CHGINTERNALMARK' in os.environ:
583 599 del os.environ['CHGINTERNALMARK']
General Comments 0
You need to be logged in to leave comments. Login now