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