如果需要多个stdin输入,python asyncio会死锁

问题描述 投票:11回答:3

我编写了一个命令行工具,使用python asyncio为多个git repos执行git pull。如果所有repos都有ssh无密码登录设置,它工作正常。如果只有1个repo需要密码输入,它也可以正常工作。当多个repos需要密码输入时,它似乎陷入僵局。

我的实现非常简单。主要逻辑是

utils.exec_async_tasks(
        utils.run_async(path, cmds) for path in repos.values())

run_async创建并等待子进程调用,exec_async_tasks运行所有任务。

async def run_async(path: str, cmds: List[str]):
    """
    Run `cmds` asynchronously in `path` directory
    """
    process = await asyncio.create_subprocess_exec(
        *cmds, stdout=asyncio.subprocess.PIPE, cwd=path)
    stdout, _ = await process.communicate()
    stdout and print(stdout.decode())


def exec_async_tasks(tasks: List[Coroutine]):
    """
    Execute tasks asynchronously
    """
    # TODO: asyncio API is nicer in python 3.7
    if platform.system() == 'Windows':
        loop = asyncio.ProactorEventLoop()
        asyncio.set_event_loop(loop)
    else:
        loop = asyncio.get_event_loop()

    try:
        loop.run_until_complete(asyncio.gather(*tasks))
    finally:
        loop.close()

完整的代码库是here on github

我认为问题类似于以下内容。在run_asyncasyncio.create_subprocess_exec中,stdin没有重定向,系统的stdin用于所有子进程(repos)。当第一个repo请求输入密码时,asyncio scheduler会看到阻塞输入,并在等待命令行输入时切换到第二个repo。但是如果第二个repo在第一个repo的密码输入完成之前要求输入密码,系统的stdin将链接到第二个repo。而第一个回购将永远等待输入。

我不知道如何处理这种情况。我是否必须为每个子进程重定向stdin?如果一些回购有无密码登录而有些没有?

一些想法如下

  1. create_subprocess_exec中检测何时需要输入密码。如果是,则调用input()并将其结果传递给process.communicate(input)。但是我怎么能在飞行中发现它呢?
  2. 检测哪个repos需要密码输入,并将它们从异步执行中排除。最好的方法是什么?
python git subprocess stdin python-asyncio
3个回答
6
投票

在默认配置中,当需要用户名或密码时,gitdirectly access the /dev/tty synonym用于更好地控制“控制”终端设备,例如允许您与用户交互的设备。由于子进程默认从其父进程继承控制终端,因此您启动的所有git进程都将访问相同的TTY设备。所以,是的,当他们尝试读取和写入相同的TTY时,他们会挂起,其中的进程会破坏彼此的预期输入。

防止这种情况发生的简单方法是为每个子进程提供自己的会话;不同的会话每个都有不同的控制TTY。通过设置start_new_session=True来这样做:

process = await asyncio.create_subprocess_exec(
    *cmds, stdout=asyncio.subprocess.PIPE, cwd=path, start_new_session=True)

您无法真正确定git命令可能需要用户凭据的前期,因为git可以配置为从各种位置获取凭据,并且仅在远程存储库实际上要求进行身份验证时才使用这些凭据。

更糟糕的是,对于ssh://远程URL,git根本不处理身份验证,而是将其留给它打开的ssh客户端进程。更多关于以下内容。

Git如何要求凭证(除了ssh之外的任何东西)是可配置的;看到gitcredentials documentation。如果您的代码必须能够将证书请求转发给最终用户,则可以使用此功能。我不会通过终端将它留给git命令来执行此操作,因为用户将如何知道哪些特定的git命令将接收哪些凭据,更不用说您在确保提示到达时所遇到的问题逻辑顺序。

相反,我会通过您的脚本路由所有凭据请求。您有两种选择:

  • 设置GIT_ASKPASS环境变量,指向git应为每个提示运行的可执行文件。 使用单个参数调用此可执行文件,即显示用户的提示。对于给定凭证所需的每条信息,它都是单独调用的,因此对于用户名(如果尚未知道)和密码。提示文本应该向用户说明要求的内容(例如"Username for 'https://github.com': ""Password for 'https://[email protected]': "
  • 注册一个credential helper;这是作为shell命令执行的(因此可以有自己的预配置命令行参数),还有一个额外的参数告诉帮助程序对它有什么样的操作。如果它通过get作为最后一个参数,那么它被要求提供给定主机和协议的凭证,或者可以告诉它使用store获得某些凭证,或者被erase拒绝。在所有情况下,它都可以从stdin中读取信息,以多行key=value格式了解主机git尝试进行身份验证的信息。 因此,使用凭证帮助程序,您可以将一个用户名和密码组合作为一个步骤进行提示,并且您还可以获得有关该流程的更多信息;处理storeerase操作可以更有效地缓存凭据。

Git fill首先按配置顺序询问每个配置的凭证助手(请参阅FILES section to understand how the 4 config file locations按顺序处理)。您可以使用git命令行开关在-c credential.helper=...命令行上添加新的一次性帮助程序配置,该命令行添加到最后。如果没有凭证助手能够填写缺少的用户名或密码,则会提示用户使用GIT_ASKPASSthe other prompting options

对于SSH连接,git会创建一个新的ssh子进程。然后,SSH将处理身份验证,并可能要求用户提供凭据或ssh密钥,请求用户输入密码。这将再次通过/dev/tty完成,而SSH对此更加固执。虽然您可以将SSH_ASKPASS环境变量设置为用于提示的二进制文件,但SSH将仅使用此if there is no TTY session and DISPLAY is also set

SSH_ASKPASS必须是可执行文件(因此不能传递参数),并且不会通知您提示凭据的成功或失败。

我还要确保将当前环境变量复制到子进程,因为如果用户已经设置了SSH密钥代理来缓存ssh密钥,那么你需要git开始使用它们的SSH进程;通过环境变量发现密钥代理。

因此,要创建凭证帮助程序的连接,以及也适用于SSH_ASKPASS的连接,您可以使用从环境变量获取套接字的简单同步脚本:

#!/path/to/python3
import os, socket, sys
path = os.environ['PROMPTING_SOCKET_PATH']
operation = sys.argv[1]
if operation not in {'get', 'store', 'erase'}:
    operation, params = 'prompt', f'prompt={operation}\n'
else:
    params = sys.stdin.read()
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as s:
    s.connect(path)
    s.sendall(f'''operation={operation}\n{params}'''.encode())
    print(s.recv(2048).decode())

这应该设置可执行位。

然后可以将其作为临时文件传递给git命令或包含预构建,并在PROMPTING_SOCKET_PATH环境变量中添加Unix域套接字路径。它可以兼作SSH_ASKPASS提示器,将操作设置为prompt

然后,此脚本使SSH和git在每个用户的单独连接中向UNIX域套接字服务器询问用户凭据。我已经使用了一个宽大的接收缓冲区大小,我不认为你会遇到超过它的协议交换,我也没有看到它被填充不足的任何理由。它使脚本保持简洁。

您可以使用它作为GIT_ASKPASS命令,但是您将无法获得有关非ssh连接的凭据成功的有价值信息。

以下是UNIX域套接字服务器的演示实现,该服务器处理来自上述凭证帮助程序的git和凭证请求,该请求只生成随机十六进制值而不是询问用户:

import asyncio
import os
import secrets
import tempfile

async def handle_git_prompt(reader, writer):
    data = await reader.read(2048)
    info = dict(line.split('=', 1) for line in data.decode().splitlines())
    print(f"Received credentials request: {info!r}")

    response = []
    operation = info.pop('operation', 'get')

    if operation == 'prompt':
        # new prompt for a username or password or pass phrase for SSH
        password = secrets.token_hex(10)
        print(f"Sending prompt response: {password!r}")
        response.append(password)

    elif operation == 'get':
        # new request for credentials, for a username (optional) and password
        if 'username' not in info:
            username = secrets.token_hex(10)
            print(f"Sending username: {username!r}")
            response.append(f'username={username}\n')

        password = secrets.token_hex(10)
        print(f"Sending password: {password!r}")
        response.append(f'password={password}\n')

    elif operation == 'store':
        # credentials were used successfully, perhaps store these for re-use
        print(f"Credentials for {info['username']} were approved")

    elif operation == 'erase':
        # credentials were rejected, if we cached anything, clear this now.
        print(f"Credentials for {info['username']} were rejected")

    writer.write(''.join(response).encode())
    await writer.drain()

    print("Closing the connection")
    writer.close()
    await writer.wait_closed()

async def main():
    with tempfile.TemporaryDirectory() as dirname:
        socket_path = os.path.join(dirname, 'credential.helper.sock')
        server = await asyncio.start_unix_server(handle_git_prompt, socket_path)

        print(f'Starting a domain socket at {server.sockets[0].getsockname()}')

        async with server:
            await server.serve_forever()

asyncio.run(main())

请注意,凭据帮助程序还可以将quit=truequit=1添加到输出中,以告知git不会查找任何其他凭据帮助程序,也不会进一步提示。

您可以使用git credential <operation> command来测试凭证帮助程序是否有效,方法是使用git /full/path/to/credhelper.py命令行选项传入帮助程序脚本(-c credential.helper=...)。 git credential可以在标准输入上使用url=...字符串,它将解析它,就像git会联系凭证助手一样;请参阅完整交换格式规范的文档。

首先,在一个单独的终端中启动上面的演示脚本:

$ /usr/local/bin/python3.7 git-credentials-demo.py
Starting a domain socket at /tmp/credhelper.py /var/folders/vh/80414gbd6p1cs28cfjtql3l80000gn/T/tmprxgyvecj/credential.helper.sock

然后尝试从中获取凭据;我还包括了storeerase操作的演示:

$ export PROMPTING_SOCKET_PATH="/var/folders/vh/80414gbd6p1cs28cfjtql3l80000gn/T/tmprxgyvecj/credential.helper.sock"
$ CREDHELPER="/tmp/credhelper.py"
$ echo "url=https://example.com:4242/some/path.git" | git -c "credential.helper=$CREDHELPER" credential fill
protocol=https
host=example.com:4242
username=5b5b0b9609c1a4f94119
password=e259f5be2c96fed718e6
$ echo "url=https://[email protected]/some/path.git" | git -c "credential.helper=$CREDHELPER" credential fill
protocol=https
host=example.com
username=someuser
password=766df0fba1de153c3e99
$ printf "protocol=https\nhost=example.com:4242\nusername=5b5b0b9609c1a4f94119\npassword=e259f5be2c96fed718e6" | git -c "credential.helper=$CREDHELPER" credential approve
$ printf "protocol=https\nhost=example.com\nusername=someuser\npassword=e259f5be2c96fed718e6" | git -c "credential.helper=$CREDHELPER" credential reject

然后,当您查看示例脚本的输出时,您将看到:

Received credentials request: {'operation': 'get', 'protocol': 'https', 'host': 'example.com:4242'}
Sending username: '5b5b0b9609c1a4f94119'
Sending password: 'e259f5be2c96fed718e6'
Closing the connection
Received credentials request: {'operation': 'get', 'protocol': 'https', 'host': 'example.com', 'username': 'someuser'}
Sending password: '766df0fba1de153c3e99'
Closing the connection
Received credentials request: {'operation': 'store', 'protocol': 'https', 'host': 'example.com:4242', 'username': '5b5b0b9609c1a4f94119', 'password': 'e259f5be2c96fed718e6'}
Credentials for 5b5b0b9609c1a4f94119 were approved
Closing the connection
Received credentials request: {'operation': 'erase', 'protocol': 'https', 'host': 'example.com', 'username': 'someuser', 'password': 'e259f5be2c96fed718e6'}
Credentials for someuser were rejected
Closing the connection

注意如何为protocolhost给出一个解析出的字段集,并且省略了路径;如果您设置了git配置选项credential.useHttpPath=true(或者它已经为您设置),那么path=some/path.git将被添加到传入的信息中。

对于SSH,只需调用可执行文件并显示提示:

$ $CREDHELPER "Please enter a super-secret passphrase: "
30b5978210f46bb968b2

并且演示服务器已打印:

Received credentials request: {'operation': 'prompt', 'prompt': 'Please enter a super-secret passphrase: '}
Sending prompt response: '30b5978210f46bb968b2'
Closing the connection

只需确保在启动git进程时仍然设置start_new_session=True以确保SSH被强制使用SSH_ASKPASS

env = {
    os.environ,
    SSH_ASKPASS='../path/to/credhelper.py',
    DISPLAY='dummy value',
    PROMPTING_SOCKET_PATH='../path/to/domain/socket',
}
process = await asyncio.create_subprocess_exec(
    *cmds, stdout=asyncio.subprocess.PIPE, cwd=path, 
    start_new_session=True, env=env)

当然,您如何处理提示您的用户是一个单独的问题,但您的脚本现在具有完全控制权(每个git命令将耐心等待凭证助手返回所请求的信息)并且您可以排队请求以供用户填写在,您可以根据需要缓存凭据(如果多个命令都在等待同一主机的凭据)。


4
投票

一般来说,向qit提供密码的推荐方法是通过“凭证助手”或GIT_ASKPASS,如answer of Martijn所指出的,但对于Git + SSH,情况很复杂(下面将进行更多讨论)。因此,在OS中正确设置它很困难。如果您只想快速修补脚本,这里的代码适用于Linux和Windows:

async def run_async(...):
    ...
    process = await asyncio.create_subprocess_exec( *cmds, 
        stdin=asyncio.subprocess.PIPE, 
        stdout=asyncio.subprocess.PIPE,
        stderr=asyncio.subprocess.PIPE, 
        start_new_session=True, cwd=path)
    stdout, stderr = await process.communicate(password + b'\n')

参数start_new_session=True将为子进程设置一个新的SID,以便为它分配一个新的会话which have no controlling TTY by default。然后SSH将被迫从stdin管道读取密码。在Windows上,start_new_session似乎没有任何效果(Windows AFAIK上没有SID的概念)。

除非您计划在项目“gita”中实施Git-credential-manager(GCM),否则我不建议将任何密码提供给Git(unix philosophy)。只需设置stdin=asyncio.subprocess.DEVNULL并将None传递给process.communicate()。这将强制Git和SSH使用现有的CM或中止(您可以稍后处理错误)。而且,我认为“gita”不想搞砸其他CM的配置,例如GCM for windows。因此,不要费心去触摸GIT_ASKPASSSSH_ASKPASS变量,或任何credential.*配置。用户有责任(和自由)为每个回购设置适当的GCM。通常,Git发行版已经包含GCM或ASKPASS实现。

讨论

这个问题有一个常见的误解:Git没有打开TTY进行密码输入,SSH确实如此!实际上,其他与ssh相关的实用程序,例如rsyncscp,也有相同的行为(几个月前调试SELinux相关问题时,我认为这很难)。请参阅附录以进行验证。

因为Git将SSH称为子进程,所以无法知道SSH是否会打开TTY。 Git配置,例如core.askpassGIT_ASKPASS,不会阻止SSH打开/dev/tty,至少在我使用CentOS 7上的Git 1.8.3进行测试时没有(详见附录)。有两种常见情况需要密码提示:

  • 服务器需要密码验证;
  • 对于公钥验证,私钥存储(在本地文件~/.ssh/id_rsa或PKCS11芯片中)受密码保护。

在这些情况下,ASKPASS或GCM无法帮助您解决死锁问题。您必须禁用TTY。

您可能还想了解环境变量SSH_ASKPASS。它指向在满足以下条件时将调用的可执行文件:

  • 当前会话无法控制TTY;
  • 信封。变量DISPLAY已设置。

例如,在Windows上,它默认为SSH_ASKPASS=/mingw64/libexec/git-core/git-gui--askpass。该程序附带main-stream distribution和官方Git-GUI包。因此,在Windows和Linux桌面环境中,如果您通过start_new_session=True禁用TTY并保持其他配置不变,SSH将自动弹出separate UI window以获取密码提示。

Appendix

要验证哪个进程打开TTY,您可以在Git进程等待密码时运行ps -fo pid,tty,cmd

$ ps -fo pid,tty,cmd
3839452 pts/0         \_ git clone ssh://username@hostname/path/to/repo ./repo
3839453 pts/0             \_ ssh username@hostname git-upload-pack '/path/to/repo'

$ ls -l /proc/3839453/fd /proc/3839452/fd
/proc/3839452/fd:
total 0
lrwx------. 1 xxx xxx 64 Apr  4 21:45 0 -> /dev/pts/0
lrwx------. 1 xxx xxx 64 Apr  4 21:45 1 -> /dev/pts/0
lrwx------. 1 xxx xxx 64 Apr  4 21:43 2 -> /dev/pts/0
l-wx------. 1 xxx xxx 64 Apr  4 21:45 4 -> pipe:[49095162]
lr-x------. 1 xxx xxx 64 Apr  4 21:45 5 -> pipe:[49095163]

/proc/3839453/fd:
total 0
lr-x------. 1 xxx xxx 64 Apr  4 21:42 0 -> pipe:[49095162]
l-wx------. 1 xxx xxx 64 Apr  4 21:42 1 -> pipe:[49095163]
lrwx------. 1 xxx xxx 64 Apr  4 21:42 2 -> /dev/pts/0
lrwx------. 1 xxx xxx 64 Apr  4 21:42 3 -> socket:[49091282]
lrwx------. 1 xxx xxx 64 Apr  4 21:45 4 -> /dev/tty

1
投票

我最终使用@vincent建议的简单解决方案,即通过设置GIT_ASKPASS环境变量禁用任何现有密码机制,在所有repos上运行async,并同步重新运行失败的密码。

主要逻辑变为

cache = os.environ.get('GIT_ASKPASS')
os.environ['GIT_ASKPASS'] = 'echo'
errors = utils.exec_async_tasks(
    utils.run_async(path, cmds) for path in repos.values())
# Reset context and re-run
if cache:
    os.environ['GIT_ASKPASS'] = cache
else:
    del os.environ['GIT_ASKPASS']
for path in errors:
    if path:
        subprocess.run(cmds, cwd=path)

run_asyncexec_async_tasks中,我只是重定向错误并在子进程执行失败时返回repo path

async def run_async(path: str, cmds: List[str]) -> Union[None, str]:
    """
    Run `cmds` asynchronously in `path` directory. Return the `path` if
    execution fails.
    """
    process = await asyncio.create_subprocess_exec(
        *cmds,
        stdout=asyncio.subprocess.PIPE,
        stderr=asyncio.subprocess.PIPE,
        cwd=path)
    stdout, stderr = await process.communicate()
    stdout and print(stdout.decode())
    if stderr:
        return path

你可以看到this pull request的完整变化。

进一步更新

上面的PR解决了https类型远程需要用户名/密码输入时的问题,但是当ssh需要多个repos的密码输入时仍然有问题。感谢@ gdlmx的评论如下。

在0.9.1版本中,我基本上遵循@ gdlmx的建议:在异步模式下运行时完全禁用用户输入,失败的repos将使用subprocess连续再次运行委派命令。

© www.soinside.com 2019 - 2024. All rights reserved.