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