##// END OF EJS Templates
chgserver: catch Abort while parsing early args to shut down cleanly...
Yuya Nishihara -
r40146:d1338b4e default
parent child Browse files
Show More
@@ -1,625 +1,632
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
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 'setenv' command
20 20 replace os.environ completely
21 21
22 22 'setumask' command (DEPRECATED)
23 23 'setumask2' command
24 24 set umask
25 25
26 26 'validate' command
27 27 reload the config and check if the server is up to date
28 28
29 29 Config
30 30 ------
31 31
32 32 ::
33 33
34 34 [chgserver]
35 35 # how long (in seconds) should an idle chg server exit
36 36 idletimeout = 3600
37 37
38 38 # whether to skip config or env change checks
39 39 skiphash = False
40 40 """
41 41
42 42 from __future__ import absolute_import
43 43
44 44 import hashlib
45 45 import inspect
46 46 import os
47 47 import re
48 48 import socket
49 49 import stat
50 50 import struct
51 51 import time
52 52
53 53 from .i18n import _
54 54
55 55 from . import (
56 56 commandserver,
57 57 encoding,
58 58 error,
59 59 extensions,
60 60 node,
61 61 pycompat,
62 62 util,
63 63 )
64 64
65 65 from .utils import (
66 66 procutil,
67 67 )
68 68
69 69 _log = commandserver.log
70 70
71 71 def _hashlist(items):
72 72 """return sha1 hexdigest for a list"""
73 73 return node.hex(hashlib.sha1(str(items)).digest())
74 74
75 75 # sensitive config sections affecting confighash
76 76 _configsections = [
77 77 'alias', # affects global state commands.table
78 78 'eol', # uses setconfig('eol', ...)
79 79 'extdiff', # uisetup will register new commands
80 80 'extensions',
81 81 ]
82 82
83 83 _configsectionitems = [
84 84 ('commands', 'show.aliasprefix'), # show.py reads it in extsetup
85 85 ]
86 86
87 87 # sensitive environment variables affecting confighash
88 88 _envre = re.compile(r'''\A(?:
89 89 CHGHG
90 90 |HG(?:DEMANDIMPORT|EMITWARNINGS|MODULEPOLICY|PROF|RCPATH)?
91 91 |HG(?:ENCODING|PLAIN).*
92 92 |LANG(?:UAGE)?
93 93 |LC_.*
94 94 |LD_.*
95 95 |PATH
96 96 |PYTHON.*
97 97 |TERM(?:INFO)?
98 98 |TZ
99 99 )\Z''', re.X)
100 100
101 101 def _confighash(ui):
102 102 """return a quick hash for detecting config/env changes
103 103
104 104 confighash is the hash of sensitive config items and environment variables.
105 105
106 106 for chgserver, it is designed that once confighash changes, the server is
107 107 not qualified to serve its client and should redirect the client to a new
108 108 server. different from mtimehash, confighash change will not mark the
109 109 server outdated and exit since the user can have different configs at the
110 110 same time.
111 111 """
112 112 sectionitems = []
113 113 for section in _configsections:
114 114 sectionitems.append(ui.configitems(section))
115 115 for section, item in _configsectionitems:
116 116 sectionitems.append(ui.config(section, item))
117 117 sectionhash = _hashlist(sectionitems)
118 118 # If $CHGHG is set, the change to $HG should not trigger a new chg server
119 119 if 'CHGHG' in encoding.environ:
120 120 ignored = {'HG'}
121 121 else:
122 122 ignored = set()
123 123 envitems = [(k, v) for k, v in encoding.environ.iteritems()
124 124 if _envre.match(k) and k not in ignored]
125 125 envhash = _hashlist(sorted(envitems))
126 126 return sectionhash[:6] + envhash[:6]
127 127
128 128 def _getmtimepaths(ui):
129 129 """get a list of paths that should be checked to detect change
130 130
131 131 The list will include:
132 132 - extensions (will not cover all files for complex extensions)
133 133 - mercurial/__version__.py
134 134 - python binary
135 135 """
136 136 modules = [m for n, m in extensions.extensions(ui)]
137 137 try:
138 138 from . import __version__
139 139 modules.append(__version__)
140 140 except ImportError:
141 141 pass
142 142 files = [pycompat.sysexecutable]
143 143 for m in modules:
144 144 try:
145 145 files.append(inspect.getabsfile(m))
146 146 except TypeError:
147 147 pass
148 148 return sorted(set(files))
149 149
150 150 def _mtimehash(paths):
151 151 """return a quick hash for detecting file changes
152 152
153 153 mtimehash calls stat on given paths and calculate a hash based on size and
154 154 mtime of each file. mtimehash does not read file content because reading is
155 155 expensive. therefore it's not 100% reliable for detecting content changes.
156 156 it's possible to return different hashes for same file contents.
157 157 it's also possible to return a same hash for different file contents for
158 158 some carefully crafted situation.
159 159
160 160 for chgserver, it is designed that once mtimehash changes, the server is
161 161 considered outdated immediately and should no longer provide service.
162 162
163 163 mtimehash is not included in confighash because we only know the paths of
164 164 extensions after importing them (there is imp.find_module but that faces
165 165 race conditions). We need to calculate confighash without importing.
166 166 """
167 167 def trystat(path):
168 168 try:
169 169 st = os.stat(path)
170 170 return (st[stat.ST_MTIME], st.st_size)
171 171 except OSError:
172 172 # could be ENOENT, EPERM etc. not fatal in any case
173 173 pass
174 174 return _hashlist(map(trystat, paths))[:12]
175 175
176 176 class hashstate(object):
177 177 """a structure storing confighash, mtimehash, paths used for mtimehash"""
178 178 def __init__(self, confighash, mtimehash, mtimepaths):
179 179 self.confighash = confighash
180 180 self.mtimehash = mtimehash
181 181 self.mtimepaths = mtimepaths
182 182
183 183 @staticmethod
184 184 def fromui(ui, mtimepaths=None):
185 185 if mtimepaths is None:
186 186 mtimepaths = _getmtimepaths(ui)
187 187 confighash = _confighash(ui)
188 188 mtimehash = _mtimehash(mtimepaths)
189 189 _log('confighash = %s mtimehash = %s\n' % (confighash, mtimehash))
190 190 return hashstate(confighash, mtimehash, mtimepaths)
191 191
192 192 def _newchgui(srcui, csystem, attachio):
193 193 class chgui(srcui.__class__):
194 194 def __init__(self, src=None):
195 195 super(chgui, self).__init__(src)
196 196 if src:
197 197 self._csystem = getattr(src, '_csystem', csystem)
198 198 else:
199 199 self._csystem = csystem
200 200
201 201 def _runsystem(self, cmd, environ, cwd, out):
202 202 # fallback to the original system method if
203 203 # a. the output stream is not stdout (e.g. stderr, cStringIO),
204 204 # b. or stdout is redirected by protectstdio(),
205 205 # because the chg client is not aware of these situations and
206 206 # will behave differently (i.e. write to stdout).
207 207 if (out is not self.fout
208 208 or not util.safehasattr(self.fout, 'fileno')
209 209 or self.fout.fileno() != procutil.stdout.fileno()
210 210 or self._finoutredirected):
211 211 return procutil.system(cmd, environ=environ, cwd=cwd, out=out)
212 212 self.flush()
213 213 return self._csystem(cmd, procutil.shellenviron(environ), cwd)
214 214
215 215 def _runpager(self, cmd, env=None):
216 216 self._csystem(cmd, procutil.shellenviron(env), type='pager',
217 217 cmdtable={'attachio': attachio})
218 218 return True
219 219
220 220 return chgui(srcui)
221 221
222 222 def _loadnewui(srcui, args):
223 223 from . import dispatch # avoid cycle
224 224
225 225 newui = srcui.__class__.load()
226 226 for a in ['fin', 'fout', 'ferr', 'environ']:
227 227 setattr(newui, a, getattr(srcui, a))
228 228 if util.safehasattr(srcui, '_csystem'):
229 229 newui._csystem = srcui._csystem
230 230
231 231 # command line args
232 232 options = dispatch._earlyparseopts(newui, args)
233 233 dispatch._parseconfig(newui, options['config'])
234 234
235 235 # stolen from tortoisehg.util.copydynamicconfig()
236 236 for section, name, value in srcui.walkconfig():
237 237 source = srcui.configsource(section, name)
238 238 if ':' in source or source == '--config' or source.startswith('$'):
239 239 # path:line or command line, or environ
240 240 continue
241 241 newui.setconfig(section, name, value, source)
242 242
243 243 # load wd and repo config, copied from dispatch.py
244 244 cwd = options['cwd']
245 245 cwd = cwd and os.path.realpath(cwd) or None
246 246 rpath = options['repository']
247 247 path, newlui = dispatch._getlocal(newui, rpath, wd=cwd)
248 248
249 249 return (newui, newlui)
250 250
251 251 class channeledsystem(object):
252 252 """Propagate ui.system() request in the following format:
253 253
254 254 payload length (unsigned int),
255 255 type, '\0',
256 256 cmd, '\0',
257 257 cwd, '\0',
258 258 envkey, '=', val, '\0',
259 259 ...
260 260 envkey, '=', val
261 261
262 262 if type == 'system', waits for:
263 263
264 264 exitcode length (unsigned int),
265 265 exitcode (int)
266 266
267 267 if type == 'pager', repetitively waits for a command name ending with '\n'
268 268 and executes it defined by cmdtable, or exits the loop if the command name
269 269 is empty.
270 270 """
271 271 def __init__(self, in_, out, channel):
272 272 self.in_ = in_
273 273 self.out = out
274 274 self.channel = channel
275 275
276 276 def __call__(self, cmd, environ, cwd=None, type='system', cmdtable=None):
277 277 args = [type, procutil.quotecommand(cmd), os.path.abspath(cwd or '.')]
278 278 args.extend('%s=%s' % (k, v) for k, v in environ.iteritems())
279 279 data = '\0'.join(args)
280 280 self.out.write(struct.pack('>cI', self.channel, len(data)))
281 281 self.out.write(data)
282 282 self.out.flush()
283 283
284 284 if type == 'system':
285 285 length = self.in_.read(4)
286 286 length, = struct.unpack('>I', length)
287 287 if length != 4:
288 288 raise error.Abort(_('invalid response'))
289 289 rc, = struct.unpack('>i', self.in_.read(4))
290 290 return rc
291 291 elif type == 'pager':
292 292 while True:
293 293 cmd = self.in_.readline()[:-1]
294 294 if not cmd:
295 295 break
296 296 if cmdtable and cmd in cmdtable:
297 297 _log('pager subcommand: %s' % cmd)
298 298 cmdtable[cmd]()
299 299 else:
300 300 raise error.Abort(_('unexpected command: %s') % cmd)
301 301 else:
302 302 raise error.ProgrammingError('invalid S channel type: %s' % type)
303 303
304 304 _iochannels = [
305 305 # server.ch, ui.fp, mode
306 306 ('cin', 'fin', r'rb'),
307 307 ('cout', 'fout', r'wb'),
308 308 ('cerr', 'ferr', r'wb'),
309 309 ]
310 310
311 311 class chgcmdserver(commandserver.server):
312 312 def __init__(self, ui, repo, fin, fout, sock, hashstate, baseaddress):
313 313 super(chgcmdserver, self).__init__(
314 314 _newchgui(ui, channeledsystem(fin, fout, 'S'), self.attachio),
315 315 repo, fin, fout)
316 316 self.clientsock = sock
317 317 self._ioattached = False
318 318 self._oldios = [] # original (self.ch, ui.fp, fd) before "attachio"
319 319 self.hashstate = hashstate
320 320 self.baseaddress = baseaddress
321 321 if hashstate is not None:
322 322 self.capabilities = self.capabilities.copy()
323 323 self.capabilities['validate'] = chgcmdserver.validate
324 324
325 325 def cleanup(self):
326 326 super(chgcmdserver, self).cleanup()
327 327 # dispatch._runcatch() does not flush outputs if exception is not
328 328 # handled by dispatch._dispatch()
329 329 self.ui.flush()
330 330 self._restoreio()
331 331 self._ioattached = False
332 332
333 333 def attachio(self):
334 334 """Attach to client's stdio passed via unix domain socket; all
335 335 channels except cresult will no longer be used
336 336 """
337 337 # tell client to sendmsg() with 1-byte payload, which makes it
338 338 # distinctive from "attachio\n" command consumed by client.read()
339 339 self.clientsock.sendall(struct.pack('>cI', 'I', 1))
340 340 clientfds = util.recvfds(self.clientsock.fileno())
341 341 _log('received fds: %r\n' % clientfds)
342 342
343 343 ui = self.ui
344 344 ui.flush()
345 345 self._saveio()
346 346 for fd, (cn, fn, mode) in zip(clientfds, _iochannels):
347 347 assert fd > 0
348 348 fp = getattr(ui, fn)
349 349 os.dup2(fd, fp.fileno())
350 350 os.close(fd)
351 351 if self._ioattached:
352 352 continue
353 353 # reset buffering mode when client is first attached. as we want
354 354 # to see output immediately on pager, the mode stays unchanged
355 355 # when client re-attached. ferr is unchanged because it should
356 356 # be unbuffered no matter if it is a tty or not.
357 357 if fn == 'ferr':
358 358 newfp = fp
359 359 else:
360 360 # make it line buffered explicitly because the default is
361 361 # decided on first write(), where fout could be a pager.
362 362 if fp.isatty():
363 363 bufsize = 1 # line buffered
364 364 else:
365 365 bufsize = -1 # system default
366 366 newfp = os.fdopen(fp.fileno(), mode, bufsize)
367 367 setattr(ui, fn, newfp)
368 368 setattr(self, cn, newfp)
369 369
370 370 self._ioattached = True
371 371 self.cresult.write(struct.pack('>i', len(clientfds)))
372 372
373 373 def _saveio(self):
374 374 if self._oldios:
375 375 return
376 376 ui = self.ui
377 377 for cn, fn, _mode in _iochannels:
378 378 ch = getattr(self, cn)
379 379 fp = getattr(ui, fn)
380 380 fd = os.dup(fp.fileno())
381 381 self._oldios.append((ch, fp, fd))
382 382
383 383 def _restoreio(self):
384 384 ui = self.ui
385 385 for (ch, fp, fd), (cn, fn, _mode) in zip(self._oldios, _iochannels):
386 386 newfp = getattr(ui, fn)
387 387 # close newfp while it's associated with client; otherwise it
388 388 # would be closed when newfp is deleted
389 389 if newfp is not fp:
390 390 newfp.close()
391 391 # restore original fd: fp is open again
392 392 os.dup2(fd, fp.fileno())
393 393 os.close(fd)
394 394 setattr(self, cn, ch)
395 395 setattr(ui, fn, fp)
396 396 del self._oldios[:]
397 397
398 398 def validate(self):
399 399 """Reload the config and check if the server is up to date
400 400
401 401 Read a list of '\0' separated arguments.
402 402 Write a non-empty list of '\0' separated instruction strings or '\0'
403 403 if the list is empty.
404 404 An instruction string could be either:
405 405 - "unlink $path", the client should unlink the path to stop the
406 406 outdated server.
407 407 - "redirect $path", the client should attempt to connect to $path
408 408 first. If it does not work, start a new server. It implies
409 409 "reconnect".
410 410 - "exit $n", the client should exit directly with code n.
411 411 This may happen if we cannot parse the config.
412 412 - "reconnect", the client should close the connection and
413 413 reconnect.
414 414 If neither "reconnect" nor "redirect" is included in the instruction
415 415 list, the client can continue with this server after completing all
416 416 the instructions.
417 417 """
418 418 from . import dispatch # avoid cycle
419 419
420 420 args = self._readlist()
421 421 try:
422 422 self.ui, lui = _loadnewui(self.ui, args)
423 423 except error.ParseError as inst:
424 424 dispatch._formatparse(self.ui.warn, inst)
425 425 self.ui.flush()
426 426 self.cresult.write('exit 255')
427 427 return
428 except error.Abort as inst:
429 self.ui.error(_("abort: %s\n") % inst)
430 if inst.hint:
431 self.ui.error(_("(%s)\n") % inst.hint)
432 self.ui.flush()
433 self.cresult.write('exit 255')
434 return
428 435 newhash = hashstate.fromui(lui, self.hashstate.mtimepaths)
429 436 insts = []
430 437 if newhash.mtimehash != self.hashstate.mtimehash:
431 438 addr = _hashaddress(self.baseaddress, self.hashstate.confighash)
432 439 insts.append('unlink %s' % addr)
433 440 # mtimehash is empty if one or more extensions fail to load.
434 441 # to be compatible with hg, still serve the client this time.
435 442 if self.hashstate.mtimehash:
436 443 insts.append('reconnect')
437 444 if newhash.confighash != self.hashstate.confighash:
438 445 addr = _hashaddress(self.baseaddress, newhash.confighash)
439 446 insts.append('redirect %s' % addr)
440 447 _log('validate: %s\n' % insts)
441 448 self.cresult.write('\0'.join(insts) or '\0')
442 449
443 450 def chdir(self):
444 451 """Change current directory
445 452
446 453 Note that the behavior of --cwd option is bit different from this.
447 454 It does not affect --config parameter.
448 455 """
449 456 path = self._readstr()
450 457 if not path:
451 458 return
452 459 _log('chdir to %r\n' % path)
453 460 os.chdir(path)
454 461
455 462 def setumask(self):
456 463 """Change umask (DEPRECATED)"""
457 464 # BUG: this does not follow the message frame structure, but kept for
458 465 # backward compatibility with old chg clients for some time
459 466 self._setumask(self._read(4))
460 467
461 468 def setumask2(self):
462 469 """Change umask"""
463 470 data = self._readstr()
464 471 if len(data) != 4:
465 472 raise ValueError('invalid mask length in setumask2 request')
466 473 self._setumask(data)
467 474
468 475 def _setumask(self, data):
469 476 mask = struct.unpack('>I', data)[0]
470 477 _log('setumask %r\n' % mask)
471 478 os.umask(mask)
472 479
473 480 def runcommand(self):
474 481 # pager may be attached within the runcommand session, which should
475 482 # be detached at the end of the session. otherwise the pager wouldn't
476 483 # receive EOF.
477 484 globaloldios = self._oldios
478 485 self._oldios = []
479 486 try:
480 487 return super(chgcmdserver, self).runcommand()
481 488 finally:
482 489 self._restoreio()
483 490 self._oldios = globaloldios
484 491
485 492 def setenv(self):
486 493 """Clear and update os.environ
487 494
488 495 Note that not all variables can make an effect on the running process.
489 496 """
490 497 l = self._readlist()
491 498 try:
492 499 newenv = dict(s.split('=', 1) for s in l)
493 500 except ValueError:
494 501 raise ValueError('unexpected value in setenv request')
495 502 _log('setenv: %r\n' % sorted(newenv.keys()))
496 503 encoding.environ.clear()
497 504 encoding.environ.update(newenv)
498 505
499 506 capabilities = commandserver.server.capabilities.copy()
500 507 capabilities.update({'attachio': attachio,
501 508 'chdir': chdir,
502 509 'runcommand': runcommand,
503 510 'setenv': setenv,
504 511 'setumask': setumask,
505 512 'setumask2': setumask2})
506 513
507 514 if util.safehasattr(procutil, 'setprocname'):
508 515 def setprocname(self):
509 516 """Change process title"""
510 517 name = self._readstr()
511 518 _log('setprocname: %r\n' % name)
512 519 procutil.setprocname(name)
513 520 capabilities['setprocname'] = setprocname
514 521
515 522 def _tempaddress(address):
516 523 return '%s.%d.tmp' % (address, os.getpid())
517 524
518 525 def _hashaddress(address, hashstr):
519 526 # if the basename of address contains '.', use only the left part. this
520 527 # makes it possible for the client to pass 'server.tmp$PID' and follow by
521 528 # an atomic rename to avoid locking when spawning new servers.
522 529 dirname, basename = os.path.split(address)
523 530 basename = basename.split('.', 1)[0]
524 531 return '%s-%s' % (os.path.join(dirname, basename), hashstr)
525 532
526 533 class chgunixservicehandler(object):
527 534 """Set of operations for chg services"""
528 535
529 536 pollinterval = 1 # [sec]
530 537
531 538 def __init__(self, ui):
532 539 self.ui = ui
533 540 self._idletimeout = ui.configint('chgserver', 'idletimeout')
534 541 self._lastactive = time.time()
535 542
536 543 def bindsocket(self, sock, address):
537 544 self._inithashstate(address)
538 545 self._checkextensions()
539 546 self._bind(sock)
540 547 self._createsymlink()
541 548 # no "listening at" message should be printed to simulate hg behavior
542 549
543 550 def _inithashstate(self, address):
544 551 self._baseaddress = address
545 552 if self.ui.configbool('chgserver', 'skiphash'):
546 553 self._hashstate = None
547 554 self._realaddress = address
548 555 return
549 556 self._hashstate = hashstate.fromui(self.ui)
550 557 self._realaddress = _hashaddress(address, self._hashstate.confighash)
551 558
552 559 def _checkextensions(self):
553 560 if not self._hashstate:
554 561 return
555 562 if extensions.notloaded():
556 563 # one or more extensions failed to load. mtimehash becomes
557 564 # meaningless because we do not know the paths of those extensions.
558 565 # set mtimehash to an illegal hash value to invalidate the server.
559 566 self._hashstate.mtimehash = ''
560 567
561 568 def _bind(self, sock):
562 569 # use a unique temp address so we can stat the file and do ownership
563 570 # check later
564 571 tempaddress = _tempaddress(self._realaddress)
565 572 util.bindunixsocket(sock, tempaddress)
566 573 self._socketstat = os.stat(tempaddress)
567 574 sock.listen(socket.SOMAXCONN)
568 575 # rename will replace the old socket file if exists atomically. the
569 576 # old server will detect ownership change and exit.
570 577 util.rename(tempaddress, self._realaddress)
571 578
572 579 def _createsymlink(self):
573 580 if self._baseaddress == self._realaddress:
574 581 return
575 582 tempaddress = _tempaddress(self._baseaddress)
576 583 os.symlink(os.path.basename(self._realaddress), tempaddress)
577 584 util.rename(tempaddress, self._baseaddress)
578 585
579 586 def _issocketowner(self):
580 587 try:
581 588 st = os.stat(self._realaddress)
582 589 return (st.st_ino == self._socketstat.st_ino and
583 590 st[stat.ST_MTIME] == self._socketstat[stat.ST_MTIME])
584 591 except OSError:
585 592 return False
586 593
587 594 def unlinksocket(self, address):
588 595 if not self._issocketowner():
589 596 return
590 597 # it is possible to have a race condition here that we may
591 598 # remove another server's socket file. but that's okay
592 599 # since that server will detect and exit automatically and
593 600 # the client will start a new server on demand.
594 601 util.tryunlink(self._realaddress)
595 602
596 603 def shouldexit(self):
597 604 if not self._issocketowner():
598 605 self.ui.debug('%s is not owned, exiting.\n' % self._realaddress)
599 606 return True
600 607 if time.time() - self._lastactive > self._idletimeout:
601 608 self.ui.debug('being idle too long. exiting.\n')
602 609 return True
603 610 return False
604 611
605 612 def newconnection(self):
606 613 self._lastactive = time.time()
607 614
608 615 def createcmdserver(self, repo, conn, fin, fout):
609 616 return chgcmdserver(self.ui, repo, fin, fout, conn,
610 617 self._hashstate, self._baseaddress)
611 618
612 619 def chgunixservice(ui, repo, opts):
613 620 # CHGINTERNALMARK is set by chg client. It is an indication of things are
614 621 # started by chg so other code can do things accordingly, like disabling
615 622 # demandimport or detecting chg client started by chg client. When executed
616 623 # here, CHGINTERNALMARK is no longer useful and hence dropped to make
617 624 # environ cleaner.
618 625 if 'CHGINTERNALMARK' in encoding.environ:
619 626 del encoding.environ['CHGINTERNALMARK']
620 627
621 628 if repo:
622 629 # one chgserver can serve multiple repos. drop repo information
623 630 ui.setconfig('bundle', 'mainreporoot', '', 'repo')
624 631 h = chgunixservicehandler(ui)
625 632 return commandserver.unixforkingservice(ui, repo=None, opts=opts, handler=h)
General Comments 0
You need to be logged in to leave comments. Login now