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