交互式终端

这个示例模拟一个需要持续交互的命令:先创建终端会话并连接到 PTY,运行命令、输入内容;连接断开后,再重新接回原会话。

要求

GET /v2/sandboxcapabilities.exec.pty 应为可用状态。

GET /v1/capabilities 的返回结果中包含 exec.pty 能力。

创建会话并连接

套接字可以连接已有会话。不指定会话时,daemon 会创建一个空闲后自动回收的 shell。

如果要保留会话,请先创建会话。使用 durablerestore 时也必须提供显式 id,否则返回 400

Python
TypeScript
BASE_URL = "http://127.0.0.1:18091"
WS_BASE_URL = "ws://127.0.0.1:18091"
sb = Aio(BASE_URL)

session_id = sb.post("/v2/pty/sessions", id="demo-1")["session_id"]
WS_URL = f"{WS_BASE_URL}/v2/pty/sessions/{session_id}/ws"
print(session_id)
# demo-1

id 由调用方指定,因此可以直接使用任务名称作为终端的 id。

Python
TypeScript
BASE_URL = "http://127.0.0.1:18091"
WS_BASE_URL = "ws://127.0.0.1:18091"
sb = Aio(BASE_URL)

session_id = sb.post("/v1/shell/sessions/create")["session_id"]
WS_URL = f"{WS_BASE_URL}/v1/shell/ws?session_id={session_id}"
print(session_id)
# 3c2a99fd-c16c-408b-9298-5126e1d00def

id 由 daemon 生成,没有办法指定。

执行命令不需要建立套接字连接。

POST /v2/pty/sessions/{id}/exec {command} 在同一个终端中执行命令。

POST /v1/shell/exec {command, id} 在同一个终端中执行命令。

响应包含以下字段:

session_id, command, status, output, console, exit_code

REST 和 WebSocket 共享同一个 shell。因此,通过 REST 创建的文件可以立即在终端中用 ls 看到。

连接客户端

两个路由使用相同的消息格式,因此客户端无需区分它们。

下面的客户端发送一条命令,并持续打印收到的输出,直到连接关闭:

Python
TypeScript
import asyncio, json, websockets

async def main():
    # Append &api_key=<key> to WS_URL when AIO_API_KEY is set.
    async with websockets.connect(WS_URL) as ws:
        await ws.send(json.dumps({"type": "input", "data": "uname -s\n"}))
        async for raw in ws:
            msg = json.loads(raw)
            if msg["type"] in ("output", "restore_output"):
                print(msg["data"], end="", flush=True)

asyncio.run(main())
# ~ $ uname -s
# Darwin
# ~ $

消息

{"type":"ready","session_id":"…","transport":"json","backend":"tmux","resumed":false}
{"type":"input","data":"ls\n"}
{"type":"resize","cols":120,"rows":40}
{"type":"ping","timestamp":<t>}
{"type":"output","data":"…"}
{"type":"restore_output","data":"…"}
{"type":"pong","timestamp":<t>}
{"type":"error","data":"…"}
方向type含义
← 接收ready升级后的第一帧
→ 发送input按键或命令文本
→ 发送resize调整 PTY 尺寸
→ 发送ping可选的保活
← 接收output终端输出,含 ANSI
← 接收restore_output重新连接时重放的缓冲历史
← 接收pongping 的回应
← 接收error连接或会话失败

input 里包含 \n 才会执行该行。非 JSON 文本按原始输入处理。colsrows 也可以放在 data 下。

protocol=binary 只改数据帧:二进制帧在两个方向上都是原始 PTY 字节,文本帧仍然是 JSON 控制消息 —— readyresizepingpong

不指定会话的连接

不带会话 id 打开的套接字同样会创建会话,两个路由的区别在于返回会话 id 的方式:

GET /v2/pty/ws 不带会话 id 时,shell 的生命周期与套接字完全相同。关闭套接字后无法再次访问这个 shell。

服务不会额外发送会话 id 帧;ready 帧本身包含该 id,随后会发送空屏幕画面:

{"backend":"native","resumed":false,"session_id":"afc14051-206a-4640-82c7-c46119cbaa3b","transport":"json","type":"ready"}
{"data":"\u001b[H\u001b[2J~ $ ","type":"output"}

GET /v1/shell/ws 不带 session_id 时,创建的会话不会随着套接字关闭,空闲后才会回收。因此,该会话 id 可以继续使用。

服务会在 ready 和空屏幕画面之前,单独发送一帧 session_id

{"data":"a0bcaea7-16fe-4439-8c9f-45516c81a32e","type":"session_id"}
{"backend":"native","resumed":false,"session_id":"a0bcaea7-16fe-4439-8c9f-45516c81a32e","transport":"json","type":"ready"}
{"data":"\u001b[H\u001b[2J~ $ ","type":"output"}

后端与会话限制

AIO_SHELL_BACKEND=auto(默认)会在检测到 tmux 时使用 tmux,否则使用原生 PTY。只有 tmux 后端的会话能在 daemon 重启后保留。

native 会固定使用原生 PTY。

cd 和环境变量的改动会在会话中保持。默认最多同时运行 20 个会话,空闲 3600 秒后自动关闭;可通过 AIO_SHELL_MAX_SESSIONSAIO_SHELL_SESSION_TIMEOUT_SECS 调整。

按 id 创建的会话会一直存在,直到被删除:

  • DELETE /v2/pty/sessions/{id}:删除会话。
  • DELETE /v1/shell/sessions/{session_id}:删除会话。

断连后恢复

durable=true 会在没有客户端连接时保留终端,让下一个套接字接管它。

下面先运行一个耗时命令,断开套接字,再重新连接:

Python
TypeScript
import asyncio, json, websockets

url = f"{WS_BASE_URL}/v2/pty/sessions/{session_id}/ws?durable=true"

async def main():
    first = await websockets.connect(url)
    await first.send(json.dumps({
        "type": "input",
        "data": "for i in 1 2 3 4 5; do echo tick $i; sleep 1; done\n"}))

    # Two ticks in, the client drops. The loop keeps running.
    await asyncio.sleep(2.5)
    await first.close()
    await asyncio.sleep(3.5)

    async with websockets.connect(url) as second:
        for _ in range(3):
            print(await second.recv())

asyncio.run(main())
sb.delete(f"/v2/pty/sessions/{session_id}")
Python
TypeScript
import asyncio, json, websockets

url = f"{WS_BASE_URL}/v1/shell/ws?session_id={session_id}&durable=true"

async def main():
    first = await websockets.connect(url)
    await first.send(json.dumps({
        "type": "input",
        "data": "for i in 1 2 3 4 5; do echo tick $i; sleep 1; done\n"}))

    # Two ticks in, the client drops. The loop keeps running.
    await asyncio.sleep(2.5)
    await first.close()
    await asyncio.sleep(3.5)

    async with websockets.connect(url) as second:
        for _ in range(4):
            print(await second.recv())

asyncio.run(main())
sb.delete(f"/v1/shell/sessions/{session_id}")

重新连接时,输出可能比命令实际产生的内容多一帧。

当连接到调用方已经持有的会话 id 时,daemon 会让前台任务重绘。

这样,全屏程序会重新绘制完整画面,而不是只让渲染端收到一段残缺内容。对于普通提示符,这会表现为提示符再次打印。

第二个套接字会收到 resumed: true 和一帧 relay_resumed,之后只接收无人连接期间产生的输出。

设置 restore=true 时,服务会改为回放整个缓冲区的有限快照。快照大小由 replay_bytes 决定,默认值为 10 MiB,允许范围为 256 KiB 至 10 MiB。

保留的输出存储在 daemon 内存中。只有 tmux 后端会让 shell 进程在 daemon 重启后继续存在。

相关页面