Skip to content

fix(pty): preserve ioctl pointer lifetime and report spawn limits (nocx-zewbi) - #546

Merged
shady2k merged 3 commits into
mainfrom
fix/nocx-zewbi-pty-eperm
Oct 27, 2026
Merged

shady2k merged 3 commits into
mainfrom
fix/nocx-zewbi-pty-eperm

Conversation

@shady2k

@shady2k shady2k commented Oct 27, 2026

Copy link
Copy Markdown
Owner

Cause and evidence

creack/pty v1.1.24 converts an ioctl output pointer to uintptr before
calling wrappers that can shrink the goroutine stack. TIOCGPTN can then write
the old stack address while the live index remains zero. Open pairs the new
master with /dev/pts/0, not its own slave. See upstream
PR #215 and
issue/PR #216.

The original failure is run 40409804818 attempt 1, job 121877470440 (the
latest attempt was cancelled):

2026-10-26T22:18:28.9272052Z --- FAIL: TestStartupFidelity_ZshHonoursAUserChosenZdotdir (0.02s)
2026-10-26T22:18:28.9273056Z     startup_fidelity_test.go:412: pty start: fork/exec /bin/sh: operation not permitted

The old-pin stress reproduced the lost index on the default SkyPilot pod:

OLD v1.1.24: 196608 opens, 18 mismatches (5,0,1,4,6,2)
master index=1 tty name=/dev/pts/0 slave minor=0
master index=6 tty name=/dev/pts/0 slave minor=0
Threads: 12
NoNewPrivs: 0
Seccomp: 2
Seccomp_filters: 1
Max processes: 79541 79541 processes
pids.current: 429
pids.max: max
pids.events: max 0
NEW pin: 196608 opens, 0 mismatches

Old stress: 10.03 s total test time, 48.761 s client wall time (including
stage/build). New stress: 12.85 s total test time, 51.636 s client wall time.
The zero-event sample gives a nominal rate below about 1.524 × 10⁻⁵ at
95% confidence
, not proof of zero risk; GC events are not independent.
The source change removes the invalid pointer lifetime rather than making
a retry more likely to succeed.

Limits of the evidence: pair corruption was reproduced; exact EPERM was
not. Starting on the wrong unowned slave succeeded in the first probe.
TIOCSCTTY can return EPERM when that slave belongs to another live
session. This is consistent with the CI signature, not proof of its cause. The historical CI log
has no syscall-stage or resource readings, so it cannot independently prove
which child-setup syscall failed. Pod ptrace is denied; that tracing failure
is not the reported PTY failure. Pod Go is 1.26.5, CI Go was 1.26.9.

The two pod launcher contexts also agree on security state: both have
NoNewPrivs=0, Seccomp=2, one filter, Threads=1, RLIMIT_NPROC=79541,
pids.max=max, and pids.events=max 0. pids.current was 401 without and
418 with Secret Service. These are current pod measurements, not historical
CI measurements; the filter count alone does not prove identical filter bytes.

What changed

  • TEMPORARY dependency pin to
    v1.1.25-0.20260601142114-9246436fffe8, verified Origin hash
    9246436fffe85773950f49dcc73de333da5212e6. There is no tagged release
    containing the fix yet; drop the pin when a tag carries it. go.mod records
    the hash, PR chore(beads): the xterm cutover becomes ten leaves, and the frame wire gets an owner #215, issue The order of the xterm cutover is recorded, and run geometry gets its one owner #216, and removal condition.
  • The tag-to-pin diff is precisely merged PR chore(beads): the xterm cutover becomes ten leaves, and the frame wire gets an owner #215. It carries two changes:
    safe unsafe.Pointer lifetime until the syscall, and activation of the
    existing SyscallConn.Control ioctl path instead of File.Fd. The latter
    preserves nonblocking rather than reverting it, agreeing with the raw-master
    handling nocx already owns. The intermediate pointer-only commit was not
    selected: the merged change is the reviewed state the eventual tag carries.
  • A fixed-budget, probabilistic Linux GC stress test asserts pair identity,
    not whether one shell happened to start. Every master ioctl index must equal
    its slave device minor. The 32768-open CI run is a cheap regression signal,
    not a deterministic scheduler or proof of absence.
  • Linux product spawn failures retain the original wrapped errno and add a
    direct /proc snapshot: PID, threads, NNP/seccomp, NPROC, and pids counters
    for the resolved cgroup and visible ancestors. Missing readings say why.
    No child environment or arguments are read. Other platforms explicitly state
    that Linux resource diagnostics are unavailable.

Checks

All tests below ran through default nocx-sky-test (2 CPU / 4 GB),
using go test -race -p 1 -tags gtk3,nocx_framecheck <package> for
importers. The OLD control adds only
-mod=mod -modfile=/tmp/nocx-old.mod; its adjacent sumfile is separate.
The worktree's go.mod/go.sum were not edited by the control.

Importer NEW own exit / seconds OLD control own exit / seconds
internal/pty/... 0 / 2.249, final-tree 0 / 1.255 not repeated
internal/shellintegration/... 1 / 376.469 1 / 366.761
internal/helper/session/... 0 / 164.061 not repeated
internal/app/... 1 / 604.719, package timeout 1 / 604.521, package timeout
cmd/agent-capture 0 / 1.168 not repeated
cmd/e2e-sshd 0 / 1.209 not repeated

Module selection was checked on the same pod against the retained control:

$ go list -m -mod=mod -modfile=/tmp/nocx-old.mod github.com/creack/pty
github.com/creack/pty v1.1.24
$ go list -m github.com/creack/pty
github.com/creack/pty v1.1.25-0.20260601142114-9246436fffe8

Both version queries exited 0. Both internal/app runs hit the package-wide
10-minute deadline. The test in flight differed between the pins because the
elapsed total differed, so this is not a claim that a specific test times out
on both; internal/app does not finish at this pod's pace, and its coherence
is CI's to establish. NEW was in
TestAReportMovesNoRecordStateAndTheIdleStillFollows (16 s); OLD in
TestAWorkerCannotNameARecipientOverTheSocket (2 s).

For every test that actually completed and failed, the assertion, source
line and message match between the pins, apart from random paths, ports and
fixture mtimes. No test fails only on the new pin; the only old-only failure
is this PR's pair-identity regression. The shared failures are not caused by
this pin. This is not a claim that the two full suites passed or completed.

Environment findings, present on both module graphs:

  • The pinned zsh ZLE plugins are missing; both plugin-order subtests report
    the required provisioning command.
  • Root's login profile overrides the fixture HOME, so Bash runs the native
    root prompt, not the user's fixture rc. This is the same /etc/profile
    rewrite measured in nocx-ukf54; read-only-home assertions also cannot
    enforce ordinary-user permissions under root.
  • LAUNCH_ALLARGV contains SkyPilot's own bash -c -- # Set -x ...
    provisioning command, not an argv selected by this change.
  • App SSH probes report the same publickey refusal, and the composed
    connection names missing /root/.ssh identity files on both pins.

No environment workaround, retry, skip, or wider timeout was added. The two
full importers remain unvalidated in this pod login environment; macOS CI
and the real CI Linux environment remain required.

The complete failing-test/subtest comparison follows. Each shared row
shows the common first assertion (dynamic ports/paths/timestamps omitted).

Failing test/assertion OLD v1.1.24 NEW pin
TestBashLauncher_BaselineKeepsVisiblePrompt FAIL baseline session missing markers FAIL baseline session missing markers
TestBashLauncher_EmitsMarkersAndRunsUserRc FAIL no OSC 133 A marker in FAIL no OSC 133 A marker in
TestBashLauncher_NoHomeWrites FAIL $HOME changed outside the published bundle (checked a recursive listing of names, sizes and mtimes before and after): FAIL $HOME changed outside the published bundle (checked a recursive listing of names, sizes and mtimes before and after):
TestBashLauncher_RunsUnderDash FAIL dash-parsed launcher missing markers FAIL dash-parsed launcher missing markers
TestBashLauncher_TransientRcfileIsGoneBeforeUserCode FAIL the fixture rc never ran, so the check proves nothing: FAIL the fixture rc never ran, so the check proves nothing:
TestBashSnapshotArrivesBeforeFirstPrompt FAIL timed out waiting for bash snapshot test first prompt FAIL timed out waiting for bash snapshot test first prompt
TestCapture_SaveNowAndSaveLaterOverTheRealSocket FAIL wait for history.recorded: read tcp 127.0.0.1:48126->127.0.0.1:46557: i/o timeout FAIL wait for history.recorded: read tcp 127.0.0.1:48126->127.0.0.1:46557: i/o timeout
TestEnsureInstalledRemote_ReadonlyHomeFailsOpenThenConverges FAIL publish into a read-only home succeeded; want a typed refusal FAIL publish into a read-only home succeeded; want a typed refusal
TestExecRefusalProbe_AnAcceptedExecConsumesTheChannelAndForcedCommandsLeaveNoNativeShell FAIL dial probe sshd: ssh: handshake failed: ssh: unable to authenticate, attempted methods [none publickey], no supported methods remain FAIL dial probe sshd: ssh: handshake failed: ssh: unable to authenticate, attempted methods [none publickey], no supported methods remain
TestExecRefusalProbe_OpenSSHAcceptsExecUnderEveryRestrictionWeCanConfigure FAIL subtest group FAIL subtest group
TestExecRefusalProbe_OpenSSHAcceptsExecUnderEveryRestrictionWeCanConfigure/ForceCommand_in_the_server_config FAIL dial probe sshd: ssh: handshake failed: ssh: unable to authenticate, attempted methods [none publickey], no supported methods remain FAIL dial probe sshd: ssh: handshake failed: ssh: unable to authenticate, attempted methods [none publickey], no supported methods remain
TestExecRefusalProbe_OpenSSHAcceptsExecUnderEveryRestrictionWeCanConfigure/Match_block_for_this_user_with_ForceCommand FAIL dial probe sshd: ssh: handshake failed: ssh: unable to authenticate, attempted methods [none publickey], no supported methods remain FAIL dial probe sshd: ssh: handshake failed: ssh: unable to authenticate, attempted methods [none publickey], no supported methods remain
TestExecRefusalProbe_OpenSSHAcceptsExecUnderEveryRestrictionWeCanConfigure/command=_restriction_on_the_authorized_key FAIL dial probe sshd: ssh: handshake failed: ssh: unable to authenticate, attempted methods [none publickey], no supported methods remain FAIL dial probe sshd: ssh: handshake failed: ssh: unable to authenticate, attempted methods [none publickey], no supported methods remain
TestExecRefusalProbe_OpenSSHAcceptsExecUnderEveryRestrictionWeCanConfigure/transfer-only_account_(ForceCommand_internal-sftp) FAIL dial probe sshd: ssh: handshake failed: ssh: unable to authenticate, attempted methods [none publickey], no supported methods remain FAIL dial probe sshd: ssh: handshake failed: ssh: unable to authenticate, attempted methods [none publickey], no supported methods remain
TestExecRefusalProbe_OpenSSHAcceptsExecUnderEveryRestrictionWeCanConfigure/unrestricted_account FAIL dial probe sshd: ssh: handshake failed: ssh: unable to authenticate, attempted methods [none publickey], no supported methods remain FAIL dial probe sshd: ssh: handshake failed: ssh: unable to authenticate, attempted methods [none publickey], no supported methods remain
TestExecRefusalProbe_RefusedSessionRequestLeavesTheChannelAndItsPTYUsable FAIL dial probe sshd: ssh: handshake failed: ssh: unable to authenticate, attempted methods [none publickey], no supported methods remain FAIL dial probe sshd: ssh: handshake failed: ssh: unable to authenticate, attempted methods [none publickey], no supported methods remain
TestInjectedBashStartupPreservesUserExitTrap FAIL timed out waiting for the launcher's startup prompt FAIL timed out waiting for the launcher's startup prompt
TestInjectedStartupReservesLegacyLatch FAIL subtest group FAIL subtest group
TestInjectedStartupReservesLegacyLatch/bash FAIL timed out waiting for the launcher's startup prompt FAIL timed out waiting for the launcher's startup prompt
TestLaunchCarrier_BadManifest_FailsOpenToNativeLoginShell FAIL subtest group FAIL subtest group
TestLaunchCarrier_BadManifest_FailsOpenToNativeLoginShell/hash-mismatched FAIL no native login shell marker FAIL no native login shell marker
TestLaunchCarrier_BadManifest_FailsOpenToNativeLoginShell/missing-manifest FAIL no native login shell marker FAIL no native login shell marker
TestLaunchCarrier_BadManifest_FailsOpenToNativeLoginShell/protocol-incompatible FAIL no native login shell marker FAIL no native login shell marker
TestLaunchCarrier_BadManifest_FailsOpenToNativeLoginShell/symlinked-generation-file FAIL no native login shell marker FAIL no native login shell marker
TestLaunchCarrier_BadManifest_FailsOpenToNativeLoginShell/truncated-manifest FAIL no native login shell marker FAIL no native login shell marker
TestLaunchCarrier_GoodManifest_BashExecsIntegratedShell FAIL user rc did not run FAIL user rc did not run
TestLaunchCarrier_UserRcExitStillWins FAIL timed out waiting for the launcher session to end FAIL timed out waiting for the launcher session to end
TestLiveSshd_SSHChildAssembly_ChildEstablishesOverComposedLine FAIL timed out waiting for child domain Established via its own hello FAIL timed out waiting for child domain Established via its own hello
TestLiveSshd_SSHChildAssembly_ExitFreezesTheChildBlockAndCompletesTheParent FAIL timed out waiting for child domain Established and its destination published FAIL timed out waiting for child domain Established and its destination published
TestLiveSshd_SSHChildAssembly_ForwardingRefusedParentStillActivates FAIL timed out waiting for ssh loader reports abort protocol; terminal: FAIL timed out waiting for ssh loader reports abort protocol; terminal:
TestLiveSshd_SSHChildAssembly_LateCandidateAfterParentActivation FAIL timed out waiting for refused forward abort reaches loader FAIL timed out waiting for refused forward abort reaches loader
TestLocalZshPane_ThePluginStackIntegratesInEitherOrder FAIL subtest group FAIL subtest group
TestLocalZshPane_ThePluginStackIntegratesInEitherOrder/plugins-after-nocx FAIL fast-syntax-highlighting is required by this test and is not installed. FAIL fast-syntax-highlighting is required by this test and is not installed.
TestLocalZshPane_ThePluginStackIntegratesInEitherOrder/plugins-before-nocx FAIL fast-syntax-highlighting is required by this test and is not installed. FAIL fast-syntax-highlighting is required by this test and is not installed.
TestPTYOpenKeepsItsSlaveUnderStackShrink FAIL expected lost-index regression PASS
TestReadonlyHomeFailsCleanly FAIL want ReadonlyError, got : FAIL want ReadonlyError, got :
TestStage1_FilesystemFailureOrdering FAIL subtest group FAIL subtest group
TestStage1_FilesystemFailureOrdering/the_unlink_fails,_so_nothing_is_written_at_all FAIL timed out waiting for "NOCX1 OUTCOME capability-unlink-failed" FAIL timed out waiting for "NOCX1 OUTCOME capability-unlink-failed"
TestStage1_FilesystemFailureOrdering/the_write_descriptor_cannot_be_opened FAIL timed out waiting for "NOCX1 OUTCOME capability-fd-unavailable" FAIL timed out waiting for "NOCX1 OUTCOME capability-fd-unavailable"
TestTier_BashReadsTheCapabilityFromTheDescriptorOnceAndClosesIt FAIL the user's rc could see a bearer FAIL the user's rc could see a bearer
TestTypedMux_ARefusedSessionOpensNoConnectionAndCostsNoSecondAuthentication FAIL timed out waiting for the master's own session to reach its prompt; it said: FAIL timed out waiting for the master's own session to reach its prompt; it said:
TestTypedMux_NothingIsPublishedBeforeOwnershipIsProven FAIL timed out waiting for the master's own session to reach its prompt; it said: FAIL timed out waiting for the master's own session to reach its prompt; it said:
TestTypedMux_ThePublishRidesTheUsersOwnConnection FAIL timed out waiting for the master's own session to reach its prompt; it said: FAIL timed out waiting for the master's own session to reach its prompt; it said:

Diagnostic TDD:

  • Actual NewLocal missing-executable seam: RED exit 1, then GREEN
    exit 0 / 1.038 s
    . It asserts wrapped ENOENT survives and the diagnostic
    contains the resource fields, not the environment canary.
  • Review found unsafe namespace-root mapping and first-mount selection.
    Fixtures for conservative containment, all mounts/visible ancestors,
    unreadable-first fallback, and exact proc-field matching: RED exit 1 /
    0.042 s
    , then GREEN exit 0 / 1.046 s, with the actual spawn seam.
    An independent source re-review accepted the repair.
  • Final full internal/pty/...: exit 0 / 1.255 s on the final sources.

Final source checks after lint-only changes: pair-identity stress exit 0 /
4.98 s
(6.040 s package); diagnostic fixtures plus actual spawn seam
exit 0 / 1.042 s. The enabled commit hook initially caught a narrowing
conversion and required a fixed-file-read annotation; both were corrected.
Enabled pre-commit and commit-message hooks passed on the final commit:
formatting, golangci-lint, control-goroutine/log-context ratchets, root
Prettier/ESLint, and commit-link checks. The backlog export was not staged,
so the staged backlog check was correctly skipped. Present-document check
also exited 0 with no new error. No hook was bypassed.

All tests ran through nocx-sky-test at its defaults. No retry, skip or wider
timeout was added. The earlier 4-CPU/12-GB provisioning attempts did not run
tests; the owner's corrected default-size instruction superseded them.
macOS runtime behavior is CI's to establish, not claimed from this Linux pod.

Check in three steps

  1. Read the temporary pin and upstream chore(beads): the xterm cutover becomes ten leaves, and the frame wire gets an owner #215/The order of the xterm cutover is recorded, and run geometry gets its one owner #216; compare the tag-to-pin diff.
  2. On Linux, run go test -tags gtk3,nocx_framecheck -count=6 -run TestPTYOpenKeepsItsSlaveUnderStackShrink -v ./internal/shellintegration
    through the project's test runner. The old version failed 18/196608; the
    pinned version was zero over the same sample.
  3. Run the diagnostic fixtures and TestNewLocal_SpawnFailureReportsLinuxResources
    in internal/pty. Check that ENOENT still unwraps and the snapshot has the
    resource fields without the environment canary. Then inspect the importer
    results below and the PR's macOS CI result.

Task: nocx-zewbi. No merge requested or performed.

shady2k and others added 3 commits October 27, 2026 04:22
…ocx-zewbi)

The old creack/pty ioctl wrapper can lose its output index when GC moves
its stack, pairing a master with /dev/pts/0. Equal stress samples measured
18 wrong pairs on v1.1.24 and none on the merged upstream fix. That defect
can explain a controlling-terminal EPERM, but the historical failing
syscall remains unproved.

Temporarily pin merged upstream PR #215 until a release includes it. Its
SyscallConn ioctl path also preserves nonblocking, so check every direct
consumer rather than assuming this is only a pointer change. Four pass;
the two remaining pod suites reproduce their completed failures on the
old graph, with no new-only failure beyond the intended old regression.

Keep a Linux pair-identity stress regression and add failure-only parent
resource diagnostics. Resolve cgroup membership across all visible mounts
without guessing namespace mappings, preserve wrapped errno, and make
unavailable readings explicit without inspecting the child environment.
.githooks/pre-push refused the branch with "nocx-zewbi - task does not exist":
commit-links.mjs resolves a commit's ids against the tracker export AT THE
PUSHED TIP, and the bead exists only in the database. Its one row is published
here, taken from br's own export. Nothing else in the file is touched, because
the main checkout's export also carries other agents' unpublished work.

Co-Authored-By: prime-agent <noreply@shady2k.dev>
…(nocx-zewbi)

main moved while this branch was in review — nocx-ukf54's rcfile fix landed — and GitHub runs no
pull_request workflow on a conflicted pull request, so the branch had no CI at all. The only conflict
was the tracker export: main's rows are kept whole and this branch's own row, nocx-zewbi, is taken from
br's export with its current state, so both sides survive and nothing else in the file changes.
.githooks/check-tracker-import.sh HEAD exits 0 on the result.

Co-Authored-By: prime-agent <noreply@shady2k.dev>
@shady2k
shady2k merged commit 73e160d into main Oct 27, 2026
12 checks passed
@shady2k
shady2k deleted the fix/nocx-zewbi-pty-eperm branch October 27, 2026 06:42
shady2k added a commit that referenced this pull request Oct 27, 2026
Five leaves reached their merge and this branch's export still carried them open — nocx-owbpb (PR #544),
nocx-zewbi (PR #546), nocx-ukf54 (PR #545), nocx-hyh2p (PR #542) and this branch's own four. The pushed
tip's export is what pre-push and the backlog gate read, so the closures travel with the branch rather than
waiting for a later flush.

Co-Authored-By: prime-agent <noreply@shady2k.dev>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant