##// END OF EJS Templates
wireprotoserver: check if command available before calling it...
Gregory Szorc -
r36816:7574c817 default
parent child Browse files
Show More
@@ -1,639 +1,639
1 1 # Copyright 21 May 2005 - (c) 2005 Jake Edge <jake@edge2.net>
2 2 # Copyright 2005-2007 Matt Mackall <mpm@selenic.com>
3 3 #
4 4 # This software may be used and distributed according to the terms of the
5 5 # GNU General Public License version 2 or any later version.
6 6
7 7 from __future__ import absolute_import
8 8
9 9 import contextlib
10 10 import struct
11 11 import sys
12 12 import threading
13 13
14 14 from .i18n import _
15 15 from . import (
16 16 encoding,
17 17 error,
18 18 hook,
19 19 pycompat,
20 20 util,
21 21 wireproto,
22 22 wireprototypes,
23 23 )
24 24
25 25 stringio = util.stringio
26 26
27 27 urlerr = util.urlerr
28 28 urlreq = util.urlreq
29 29
30 30 HTTP_OK = 200
31 31
32 32 HGTYPE = 'application/mercurial-0.1'
33 33 HGTYPE2 = 'application/mercurial-0.2'
34 34 HGERRTYPE = 'application/hg-error'
35 35
36 36 SSHV1 = wireprototypes.SSHV1
37 37 SSHV2 = wireprototypes.SSHV2
38 38
39 39 def decodevaluefromheaders(req, headerprefix):
40 40 """Decode a long value from multiple HTTP request headers.
41 41
42 42 Returns the value as a bytes, not a str.
43 43 """
44 44 chunks = []
45 45 i = 1
46 46 prefix = headerprefix.upper().replace(r'-', r'_')
47 47 while True:
48 48 v = req.env.get(r'HTTP_%s_%d' % (prefix, i))
49 49 if v is None:
50 50 break
51 51 chunks.append(pycompat.bytesurl(v))
52 52 i += 1
53 53
54 54 return ''.join(chunks)
55 55
56 56 class httpv1protocolhandler(wireprototypes.baseprotocolhandler):
57 57 def __init__(self, req, ui):
58 58 self._req = req
59 59 self._ui = ui
60 60
61 61 @property
62 62 def name(self):
63 63 return 'http-v1'
64 64
65 65 def getargs(self, args):
66 66 knownargs = self._args()
67 67 data = {}
68 68 keys = args.split()
69 69 for k in keys:
70 70 if k == '*':
71 71 star = {}
72 72 for key in knownargs.keys():
73 73 if key != 'cmd' and key not in keys:
74 74 star[key] = knownargs[key][0]
75 75 data['*'] = star
76 76 else:
77 77 data[k] = knownargs[k][0]
78 78 return [data[k] for k in keys]
79 79
80 80 def _args(self):
81 81 args = util.rapply(pycompat.bytesurl, self._req.form.copy())
82 82 postlen = int(self._req.env.get(r'HTTP_X_HGARGS_POST', 0))
83 83 if postlen:
84 84 args.update(urlreq.parseqs(
85 85 self._req.read(postlen), keep_blank_values=True))
86 86 return args
87 87
88 88 argvalue = decodevaluefromheaders(self._req, r'X-HgArg')
89 89 args.update(urlreq.parseqs(argvalue, keep_blank_values=True))
90 90 return args
91 91
92 92 def forwardpayload(self, fp):
93 93 if r'HTTP_CONTENT_LENGTH' in self._req.env:
94 94 length = int(self._req.env[r'HTTP_CONTENT_LENGTH'])
95 95 else:
96 96 length = int(self._req.env[r'CONTENT_LENGTH'])
97 97 # If httppostargs is used, we need to read Content-Length
98 98 # minus the amount that was consumed by args.
99 99 length -= int(self._req.env.get(r'HTTP_X_HGARGS_POST', 0))
100 100 for s in util.filechunkiter(self._req, limit=length):
101 101 fp.write(s)
102 102
103 103 @contextlib.contextmanager
104 104 def mayberedirectstdio(self):
105 105 oldout = self._ui.fout
106 106 olderr = self._ui.ferr
107 107
108 108 out = util.stringio()
109 109
110 110 try:
111 111 self._ui.fout = out
112 112 self._ui.ferr = out
113 113 yield out
114 114 finally:
115 115 self._ui.fout = oldout
116 116 self._ui.ferr = olderr
117 117
118 118 def client(self):
119 119 return 'remote:%s:%s:%s' % (
120 120 self._req.env.get('wsgi.url_scheme') or 'http',
121 121 urlreq.quote(self._req.env.get('REMOTE_HOST', '')),
122 122 urlreq.quote(self._req.env.get('REMOTE_USER', '')))
123 123
124 124 def addcapabilities(self, repo, caps):
125 125 caps.append('httpheader=%d' %
126 126 repo.ui.configint('server', 'maxhttpheaderlen'))
127 127 if repo.ui.configbool('experimental', 'httppostargs'):
128 128 caps.append('httppostargs')
129 129
130 130 # FUTURE advertise 0.2rx once support is implemented
131 131 # FUTURE advertise minrx and mintx after consulting config option
132 132 caps.append('httpmediatype=0.1rx,0.1tx,0.2tx')
133 133
134 134 compengines = wireproto.supportedcompengines(repo.ui, util.SERVERROLE)
135 135 if compengines:
136 136 comptypes = ','.join(urlreq.quote(e.wireprotosupport().name)
137 137 for e in compengines)
138 138 caps.append('compression=%s' % comptypes)
139 139
140 140 return caps
141 141
142 142 # This method exists mostly so that extensions like remotefilelog can
143 143 # disable a kludgey legacy method only over http. As of early 2018,
144 144 # there are no other known users, so with any luck we can discard this
145 145 # hook if remotefilelog becomes a first-party extension.
146 146 def iscmd(cmd):
147 147 return cmd in wireproto.commands
148 148
149 149 def parsehttprequest(repo, req, query):
150 150 """Parse the HTTP request for a wire protocol request.
151 151
152 152 If the current request appears to be a wire protocol request, this
153 153 function returns a dict with details about that request, including
154 154 an ``abstractprotocolserver`` instance suitable for handling the
155 155 request. Otherwise, ``None`` is returned.
156 156
157 157 ``req`` is a ``wsgirequest`` instance.
158 158 """
159 159 # HTTP version 1 wire protocol requests are denoted by a "cmd" query
160 160 # string parameter. If it isn't present, this isn't a wire protocol
161 161 # request.
162 162 if 'cmd' not in req.form:
163 163 return None
164 164
165 165 cmd = req.form['cmd'][0]
166 166
167 167 # The "cmd" request parameter is used by both the wire protocol and hgweb.
168 168 # While not all wire protocol commands are available for all transports,
169 169 # if we see a "cmd" value that resembles a known wire protocol command, we
170 170 # route it to a protocol handler. This is better than routing possible
171 171 # wire protocol requests to hgweb because it prevents hgweb from using
172 172 # known wire protocol commands and it is less confusing for machine
173 173 # clients.
174 174 if not iscmd(cmd):
175 175 return None
176 176
177 177 proto = httpv1protocolhandler(req, repo.ui)
178 178
179 179 return {
180 180 'cmd': cmd,
181 181 'proto': proto,
182 182 'dispatch': lambda: _callhttp(repo, req, proto, cmd),
183 183 'handleerror': lambda ex: _handlehttperror(ex, req, cmd),
184 184 }
185 185
186 186 def _httpresponsetype(ui, req, prefer_uncompressed):
187 187 """Determine the appropriate response type and compression settings.
188 188
189 189 Returns a tuple of (mediatype, compengine, engineopts).
190 190 """
191 191 # Determine the response media type and compression engine based
192 192 # on the request parameters.
193 193 protocaps = decodevaluefromheaders(req, r'X-HgProto').split(' ')
194 194
195 195 if '0.2' in protocaps:
196 196 # All clients are expected to support uncompressed data.
197 197 if prefer_uncompressed:
198 198 return HGTYPE2, util._noopengine(), {}
199 199
200 200 # Default as defined by wire protocol spec.
201 201 compformats = ['zlib', 'none']
202 202 for cap in protocaps:
203 203 if cap.startswith('comp='):
204 204 compformats = cap[5:].split(',')
205 205 break
206 206
207 207 # Now find an agreed upon compression format.
208 208 for engine in wireproto.supportedcompengines(ui, util.SERVERROLE):
209 209 if engine.wireprotosupport().name in compformats:
210 210 opts = {}
211 211 level = ui.configint('server', '%slevel' % engine.name())
212 212 if level is not None:
213 213 opts['level'] = level
214 214
215 215 return HGTYPE2, engine, opts
216 216
217 217 # No mutually supported compression format. Fall back to the
218 218 # legacy protocol.
219 219
220 220 # Don't allow untrusted settings because disabling compression or
221 221 # setting a very high compression level could lead to flooding
222 222 # the server's network or CPU.
223 223 opts = {'level': ui.configint('server', 'zliblevel')}
224 224 return HGTYPE, util.compengines['zlib'], opts
225 225
226 226 def _callhttp(repo, req, proto, cmd):
227 227 def genversion2(gen, engine, engineopts):
228 228 # application/mercurial-0.2 always sends a payload header
229 229 # identifying the compression engine.
230 230 name = engine.wireprotosupport().name
231 231 assert 0 < len(name) < 256
232 232 yield struct.pack('B', len(name))
233 233 yield name
234 234
235 235 for chunk in gen:
236 236 yield chunk
237 237
238 rsp = wireproto.dispatch(repo, proto, cmd)
239
240 238 if not wireproto.commands.commandavailable(cmd, proto):
241 239 req.respond(HTTP_OK, HGERRTYPE,
242 240 body=_('requested wire protocol command is not available '
243 241 'over HTTP'))
244 242 return []
245 243
244 rsp = wireproto.dispatch(repo, proto, cmd)
245
246 246 if isinstance(rsp, bytes):
247 247 req.respond(HTTP_OK, HGTYPE, body=rsp)
248 248 return []
249 249 elif isinstance(rsp, wireprototypes.bytesresponse):
250 250 req.respond(HTTP_OK, HGTYPE, body=rsp.data)
251 251 return []
252 252 elif isinstance(rsp, wireprototypes.streamreslegacy):
253 253 gen = rsp.gen
254 254 req.respond(HTTP_OK, HGTYPE)
255 255 return gen
256 256 elif isinstance(rsp, wireprototypes.streamres):
257 257 gen = rsp.gen
258 258
259 259 # This code for compression should not be streamres specific. It
260 260 # is here because we only compress streamres at the moment.
261 261 mediatype, engine, engineopts = _httpresponsetype(
262 262 repo.ui, req, rsp.prefer_uncompressed)
263 263 gen = engine.compressstream(gen, engineopts)
264 264
265 265 if mediatype == HGTYPE2:
266 266 gen = genversion2(gen, engine, engineopts)
267 267
268 268 req.respond(HTTP_OK, mediatype)
269 269 return gen
270 270 elif isinstance(rsp, wireprototypes.pushres):
271 271 rsp = '%d\n%s' % (rsp.res, rsp.output)
272 272 req.respond(HTTP_OK, HGTYPE, body=rsp)
273 273 return []
274 274 elif isinstance(rsp, wireprototypes.pusherr):
275 275 # This is the httplib workaround documented in _handlehttperror().
276 276 req.drain()
277 277
278 278 rsp = '0\n%s\n' % rsp.res
279 279 req.respond(HTTP_OK, HGTYPE, body=rsp)
280 280 return []
281 281 elif isinstance(rsp, wireprototypes.ooberror):
282 282 rsp = rsp.message
283 283 req.respond(HTTP_OK, HGERRTYPE, body=rsp)
284 284 return []
285 285 raise error.ProgrammingError('hgweb.protocol internal failure', rsp)
286 286
287 287 def _handlehttperror(e, req, cmd):
288 288 """Called when an ErrorResponse is raised during HTTP request processing."""
289 289
290 290 # Clients using Python's httplib are stateful: the HTTP client
291 291 # won't process an HTTP response until all request data is
292 292 # sent to the server. The intent of this code is to ensure
293 293 # we always read HTTP request data from the client, thus
294 294 # ensuring httplib transitions to a state that allows it to read
295 295 # the HTTP response. In other words, it helps prevent deadlocks
296 296 # on clients using httplib.
297 297
298 298 if (req.env[r'REQUEST_METHOD'] == r'POST' and
299 299 # But not if Expect: 100-continue is being used.
300 300 (req.env.get('HTTP_EXPECT',
301 301 '').lower() != '100-continue') or
302 302 # Or the non-httplib HTTP library is being advertised by
303 303 # the client.
304 304 req.env.get('X-HgHttp2', '')):
305 305 req.drain()
306 306 else:
307 307 req.headers.append((r'Connection', r'Close'))
308 308
309 309 # TODO This response body assumes the failed command was
310 310 # "unbundle." That assumption is not always valid.
311 311 req.respond(e, HGTYPE, body='0\n%s\n' % pycompat.bytestr(e))
312 312
313 313 return ''
314 314
315 315 def _sshv1respondbytes(fout, value):
316 316 """Send a bytes response for protocol version 1."""
317 317 fout.write('%d\n' % len(value))
318 318 fout.write(value)
319 319 fout.flush()
320 320
321 321 def _sshv1respondstream(fout, source):
322 322 write = fout.write
323 323 for chunk in source.gen:
324 324 write(chunk)
325 325 fout.flush()
326 326
327 327 def _sshv1respondooberror(fout, ferr, rsp):
328 328 ferr.write(b'%s\n-\n' % rsp)
329 329 ferr.flush()
330 330 fout.write(b'\n')
331 331 fout.flush()
332 332
333 333 class sshv1protocolhandler(wireprototypes.baseprotocolhandler):
334 334 """Handler for requests services via version 1 of SSH protocol."""
335 335 def __init__(self, ui, fin, fout):
336 336 self._ui = ui
337 337 self._fin = fin
338 338 self._fout = fout
339 339
340 340 @property
341 341 def name(self):
342 342 return wireprototypes.SSHV1
343 343
344 344 def getargs(self, args):
345 345 data = {}
346 346 keys = args.split()
347 347 for n in xrange(len(keys)):
348 348 argline = self._fin.readline()[:-1]
349 349 arg, l = argline.split()
350 350 if arg not in keys:
351 351 raise error.Abort(_("unexpected parameter %r") % arg)
352 352 if arg == '*':
353 353 star = {}
354 354 for k in xrange(int(l)):
355 355 argline = self._fin.readline()[:-1]
356 356 arg, l = argline.split()
357 357 val = self._fin.read(int(l))
358 358 star[arg] = val
359 359 data['*'] = star
360 360 else:
361 361 val = self._fin.read(int(l))
362 362 data[arg] = val
363 363 return [data[k] for k in keys]
364 364
365 365 def forwardpayload(self, fpout):
366 366 # We initially send an empty response. This tells the client it is
367 367 # OK to start sending data. If a client sees any other response, it
368 368 # interprets it as an error.
369 369 _sshv1respondbytes(self._fout, b'')
370 370
371 371 # The file is in the form:
372 372 #
373 373 # <chunk size>\n<chunk>
374 374 # ...
375 375 # 0\n
376 376 count = int(self._fin.readline())
377 377 while count:
378 378 fpout.write(self._fin.read(count))
379 379 count = int(self._fin.readline())
380 380
381 381 @contextlib.contextmanager
382 382 def mayberedirectstdio(self):
383 383 yield None
384 384
385 385 def client(self):
386 386 client = encoding.environ.get('SSH_CLIENT', '').split(' ', 1)[0]
387 387 return 'remote:ssh:' + client
388 388
389 389 def addcapabilities(self, repo, caps):
390 390 return caps
391 391
392 392 class sshv2protocolhandler(sshv1protocolhandler):
393 393 """Protocol handler for version 2 of the SSH protocol."""
394 394
395 395 @property
396 396 def name(self):
397 397 return wireprototypes.SSHV2
398 398
399 399 def _runsshserver(ui, repo, fin, fout, ev):
400 400 # This function operates like a state machine of sorts. The following
401 401 # states are defined:
402 402 #
403 403 # protov1-serving
404 404 # Server is in protocol version 1 serving mode. Commands arrive on
405 405 # new lines. These commands are processed in this state, one command
406 406 # after the other.
407 407 #
408 408 # protov2-serving
409 409 # Server is in protocol version 2 serving mode.
410 410 #
411 411 # upgrade-initial
412 412 # The server is going to process an upgrade request.
413 413 #
414 414 # upgrade-v2-filter-legacy-handshake
415 415 # The protocol is being upgraded to version 2. The server is expecting
416 416 # the legacy handshake from version 1.
417 417 #
418 418 # upgrade-v2-finish
419 419 # The upgrade to version 2 of the protocol is imminent.
420 420 #
421 421 # shutdown
422 422 # The server is shutting down, possibly in reaction to a client event.
423 423 #
424 424 # And here are their transitions:
425 425 #
426 426 # protov1-serving -> shutdown
427 427 # When server receives an empty request or encounters another
428 428 # error.
429 429 #
430 430 # protov1-serving -> upgrade-initial
431 431 # An upgrade request line was seen.
432 432 #
433 433 # upgrade-initial -> upgrade-v2-filter-legacy-handshake
434 434 # Upgrade to version 2 in progress. Server is expecting to
435 435 # process a legacy handshake.
436 436 #
437 437 # upgrade-v2-filter-legacy-handshake -> shutdown
438 438 # Client did not fulfill upgrade handshake requirements.
439 439 #
440 440 # upgrade-v2-filter-legacy-handshake -> upgrade-v2-finish
441 441 # Client fulfilled version 2 upgrade requirements. Finishing that
442 442 # upgrade.
443 443 #
444 444 # upgrade-v2-finish -> protov2-serving
445 445 # Protocol upgrade to version 2 complete. Server can now speak protocol
446 446 # version 2.
447 447 #
448 448 # protov2-serving -> protov1-serving
449 449 # Ths happens by default since protocol version 2 is the same as
450 450 # version 1 except for the handshake.
451 451
452 452 state = 'protov1-serving'
453 453 proto = sshv1protocolhandler(ui, fin, fout)
454 454 protoswitched = False
455 455
456 456 while not ev.is_set():
457 457 if state == 'protov1-serving':
458 458 # Commands are issued on new lines.
459 459 request = fin.readline()[:-1]
460 460
461 461 # Empty lines signal to terminate the connection.
462 462 if not request:
463 463 state = 'shutdown'
464 464 continue
465 465
466 466 # It looks like a protocol upgrade request. Transition state to
467 467 # handle it.
468 468 if request.startswith(b'upgrade '):
469 469 if protoswitched:
470 470 _sshv1respondooberror(fout, ui.ferr,
471 471 b'cannot upgrade protocols multiple '
472 472 b'times')
473 473 state = 'shutdown'
474 474 continue
475 475
476 476 state = 'upgrade-initial'
477 477 continue
478 478
479 479 available = wireproto.commands.commandavailable(request, proto)
480 480
481 481 # This command isn't available. Send an empty response and go
482 482 # back to waiting for a new command.
483 483 if not available:
484 484 _sshv1respondbytes(fout, b'')
485 485 continue
486 486
487 487 rsp = wireproto.dispatch(repo, proto, request)
488 488
489 489 if isinstance(rsp, bytes):
490 490 _sshv1respondbytes(fout, rsp)
491 491 elif isinstance(rsp, wireprototypes.bytesresponse):
492 492 _sshv1respondbytes(fout, rsp.data)
493 493 elif isinstance(rsp, wireprototypes.streamres):
494 494 _sshv1respondstream(fout, rsp)
495 495 elif isinstance(rsp, wireprototypes.streamreslegacy):
496 496 _sshv1respondstream(fout, rsp)
497 497 elif isinstance(rsp, wireprototypes.pushres):
498 498 _sshv1respondbytes(fout, b'')
499 499 _sshv1respondbytes(fout, b'%d' % rsp.res)
500 500 elif isinstance(rsp, wireprototypes.pusherr):
501 501 _sshv1respondbytes(fout, rsp.res)
502 502 elif isinstance(rsp, wireprototypes.ooberror):
503 503 _sshv1respondooberror(fout, ui.ferr, rsp.message)
504 504 else:
505 505 raise error.ProgrammingError('unhandled response type from '
506 506 'wire protocol command: %s' % rsp)
507 507
508 508 # For now, protocol version 2 serving just goes back to version 1.
509 509 elif state == 'protov2-serving':
510 510 state = 'protov1-serving'
511 511 continue
512 512
513 513 elif state == 'upgrade-initial':
514 514 # We should never transition into this state if we've switched
515 515 # protocols.
516 516 assert not protoswitched
517 517 assert proto.name == wireprototypes.SSHV1
518 518
519 519 # Expected: upgrade <token> <capabilities>
520 520 # If we get something else, the request is malformed. It could be
521 521 # from a future client that has altered the upgrade line content.
522 522 # We treat this as an unknown command.
523 523 try:
524 524 token, caps = request.split(b' ')[1:]
525 525 except ValueError:
526 526 _sshv1respondbytes(fout, b'')
527 527 state = 'protov1-serving'
528 528 continue
529 529
530 530 # Send empty response if we don't support upgrading protocols.
531 531 if not ui.configbool('experimental', 'sshserver.support-v2'):
532 532 _sshv1respondbytes(fout, b'')
533 533 state = 'protov1-serving'
534 534 continue
535 535
536 536 try:
537 537 caps = urlreq.parseqs(caps)
538 538 except ValueError:
539 539 _sshv1respondbytes(fout, b'')
540 540 state = 'protov1-serving'
541 541 continue
542 542
543 543 # We don't see an upgrade request to protocol version 2. Ignore
544 544 # the upgrade request.
545 545 wantedprotos = caps.get(b'proto', [b''])[0]
546 546 if SSHV2 not in wantedprotos:
547 547 _sshv1respondbytes(fout, b'')
548 548 state = 'protov1-serving'
549 549 continue
550 550
551 551 # It looks like we can honor this upgrade request to protocol 2.
552 552 # Filter the rest of the handshake protocol request lines.
553 553 state = 'upgrade-v2-filter-legacy-handshake'
554 554 continue
555 555
556 556 elif state == 'upgrade-v2-filter-legacy-handshake':
557 557 # Client should have sent legacy handshake after an ``upgrade``
558 558 # request. Expected lines:
559 559 #
560 560 # hello
561 561 # between
562 562 # pairs 81
563 563 # 0000...-0000...
564 564
565 565 ok = True
566 566 for line in (b'hello', b'between', b'pairs 81'):
567 567 request = fin.readline()[:-1]
568 568
569 569 if request != line:
570 570 _sshv1respondooberror(fout, ui.ferr,
571 571 b'malformed handshake protocol: '
572 572 b'missing %s' % line)
573 573 ok = False
574 574 state = 'shutdown'
575 575 break
576 576
577 577 if not ok:
578 578 continue
579 579
580 580 request = fin.read(81)
581 581 if request != b'%s-%s' % (b'0' * 40, b'0' * 40):
582 582 _sshv1respondooberror(fout, ui.ferr,
583 583 b'malformed handshake protocol: '
584 584 b'missing between argument value')
585 585 state = 'shutdown'
586 586 continue
587 587
588 588 state = 'upgrade-v2-finish'
589 589 continue
590 590
591 591 elif state == 'upgrade-v2-finish':
592 592 # Send the upgrade response.
593 593 fout.write(b'upgraded %s %s\n' % (token, SSHV2))
594 594 servercaps = wireproto.capabilities(repo, proto)
595 595 rsp = b'capabilities: %s' % servercaps.data
596 596 fout.write(b'%d\n%s\n' % (len(rsp), rsp))
597 597 fout.flush()
598 598
599 599 proto = sshv2protocolhandler(ui, fin, fout)
600 600 protoswitched = True
601 601
602 602 state = 'protov2-serving'
603 603 continue
604 604
605 605 elif state == 'shutdown':
606 606 break
607 607
608 608 else:
609 609 raise error.ProgrammingError('unhandled ssh server state: %s' %
610 610 state)
611 611
612 612 class sshserver(object):
613 613 def __init__(self, ui, repo, logfh=None):
614 614 self._ui = ui
615 615 self._repo = repo
616 616 self._fin = ui.fin
617 617 self._fout = ui.fout
618 618
619 619 # Log write I/O to stdout and stderr if configured.
620 620 if logfh:
621 621 self._fout = util.makeloggingfileobject(
622 622 logfh, self._fout, 'o', logdata=True)
623 623 ui.ferr = util.makeloggingfileobject(
624 624 logfh, ui.ferr, 'e', logdata=True)
625 625
626 626 hook.redirect(True)
627 627 ui.fout = repo.ui.fout = ui.ferr
628 628
629 629 # Prevent insertion/deletion of CRs
630 630 util.setbinary(self._fin)
631 631 util.setbinary(self._fout)
632 632
633 633 def serve_forever(self):
634 634 self.serveuntil(threading.Event())
635 635 sys.exit(0)
636 636
637 637 def serveuntil(self, ev):
638 638 """Serve until a threading.Event is set."""
639 639 _runsshserver(self._ui, self._repo, self._fin, self._fout, ev)
General Comments 0
You need to be logged in to leave comments. Login now