One-shot helper that opens a PTY, forks a child, execvp's the given argv with the slave as stdin/stdout/stderr, and hands the master fd back to the caller over a unix socket via SCM_RIGHTS. Wire contract is stdin JSON → stdout JSON + fd transfer per D-005; socket fd defaults to 3 with PTYC_SOCK_FD override for language runtimes whose subprocess machinery shuffles the low fd numbers. Minimal JSON parser (no deps) scoped to the exact accepted shape. Exec-failure pipe (CLOEXEC) reports child-side errors back without zombies. Window size applied via TIOCSWINSZ before fork; child becomes session leader + makes the slave its controlling TTY. Root Makefile PTYX_PRESENT typo fixed → PTYC_PRESENT, and a ptyc-test target added alongside ptyc-build / ptyc-clean. Eight smoke tests pass (happy path, env replacement, cwd, window size, bad argv, type errors, exec failure, unknown keys). Co-Authored-By: Claude <noreply@anthropic.com>
146 lines
4.2 KiB
Bash
Executable File
146 lines
4.2 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# Smoke test for ptyc. Uses python3 for the SCM_RIGHTS fd receive dance.
|
|
set -euo pipefail
|
|
|
|
HERE="$(cd "$(dirname "$0")" && pwd)"
|
|
BIN="$HERE/bin/ptyc"
|
|
|
|
if [[ ! -x "$BIN" ]]; then
|
|
echo "test: bin/ptyc not built — run 'make' first" >&2
|
|
exit 2
|
|
fi
|
|
|
|
python3 - "$BIN" <<'PY'
|
|
import errno
|
|
import json
|
|
import os
|
|
import socket
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
|
|
ptyc = sys.argv[1]
|
|
|
|
|
|
def spawn(req):
|
|
"""Launch ptyc, pipe the request in, receive fd + response."""
|
|
sock_parent, sock_child = socket.socketpair(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
child_fd = sock_child.fileno()
|
|
env = {**os.environ, "PTYC_SOCK_FD": str(child_fd)}
|
|
try:
|
|
p = subprocess.Popen(
|
|
[ptyc],
|
|
stdin=subprocess.PIPE,
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.PIPE,
|
|
pass_fds=(child_fd,),
|
|
env=env,
|
|
)
|
|
finally:
|
|
sock_child.close()
|
|
|
|
p.stdin.write(json.dumps(req).encode())
|
|
p.stdin.close()
|
|
|
|
fd = None
|
|
try:
|
|
msg, ancdata, _flags, _addr = sock_parent.recvmsg(1, socket.CMSG_SPACE(4))
|
|
for cmsg_level, cmsg_type, cmsg_data in ancdata:
|
|
if cmsg_level == socket.SOL_SOCKET and cmsg_type == socket.SCM_RIGHTS:
|
|
fd = int.from_bytes(cmsg_data[:4], "little")
|
|
break
|
|
except OSError:
|
|
pass
|
|
sock_parent.close()
|
|
|
|
stdout = p.stdout.read().decode()
|
|
stderr = p.stderr.read().decode()
|
|
code = p.wait()
|
|
return code, stdout, stderr, fd
|
|
|
|
|
|
def expect_ok(req, reads_substr=None):
|
|
code, out, err, fd = spawn(req)
|
|
assert code == 0, f"exit={code}, stderr={err!r}"
|
|
j = json.loads(out)
|
|
assert j["ok"] is True, out
|
|
assert j["pid"] > 0, out
|
|
assert fd is not None and fd >= 0, "no fd received"
|
|
pid = j["pid"]
|
|
try:
|
|
if reads_substr is not None:
|
|
chunks = []
|
|
deadline = time.time() + 5.0
|
|
while time.time() < deadline:
|
|
try:
|
|
data = os.read(fd, 4096)
|
|
except OSError as e:
|
|
if e.errno in (errno.EIO,): # child exited, PTY EOF on Linux
|
|
break
|
|
raise
|
|
if not data:
|
|
break
|
|
chunks.append(data.decode(errors="replace"))
|
|
if reads_substr in "".join(chunks):
|
|
break
|
|
got = "".join(chunks)
|
|
assert reads_substr in got, f"expected {reads_substr!r} in {got!r}"
|
|
finally:
|
|
os.close(fd)
|
|
# Child should exit on its own after producing its output for an
|
|
# `echo`; give it a moment, then reap.
|
|
try:
|
|
for _ in range(20):
|
|
rpid, _ = os.waitpid(pid, os.WNOHANG)
|
|
if rpid == pid:
|
|
break
|
|
time.sleep(0.05)
|
|
else:
|
|
os.kill(pid, 9)
|
|
os.waitpid(pid, 0)
|
|
except ChildProcessError:
|
|
pass
|
|
|
|
|
|
def expect_err(req, match_fragment):
|
|
code, out, err, fd = spawn(req)
|
|
assert code != 0, f"expected failure, got ok: {out!r}"
|
|
assert fd is None, "error path must not send an fd"
|
|
j = json.loads(err)
|
|
assert j["ok"] is False, err
|
|
assert match_fragment in j["error"], f"{match_fragment!r} not in {j['error']!r}"
|
|
|
|
|
|
# 1. Happy path: echo prints and exits cleanly.
|
|
expect_ok({"argv": ["/bin/echo", "hello-ptyc"]}, reads_substr="hello-ptyc")
|
|
|
|
# 2. env replacement works — child sees exactly the keys we pass.
|
|
expect_ok(
|
|
{"argv": ["/usr/bin/env"], "env": {"FOO": "bar", "PATH": "/usr/bin:/bin"}},
|
|
reads_substr="FOO=bar",
|
|
)
|
|
|
|
# 3. cwd respected.
|
|
expect_ok({"argv": ["/bin/sh", "-c", "pwd"], "cwd": "/tmp"}, reads_substr="/tmp")
|
|
|
|
# 4. window size propagates (stty reports it).
|
|
expect_ok(
|
|
{"argv": ["/bin/sh", "-c", "stty size"], "cols": 132, "rows": 42},
|
|
reads_substr="42 132",
|
|
)
|
|
|
|
# 5. Bad request: missing argv.
|
|
expect_err({}, "argv is required")
|
|
|
|
# 6. Bad request: argv not an array.
|
|
expect_err({"argv": "bash"}, "argv must be an array")
|
|
|
|
# 7. exec failure reported on error channel.
|
|
expect_err({"argv": ["/does/not/exist/nope"]}, "execvp")
|
|
|
|
# 8. Unknown key rejected.
|
|
expect_err({"argv": ["true"], "wat": 1}, "unknown key")
|
|
|
|
print("ptyc: all smoke tests passed")
|
|
PY
|