Skip to content

fix(adapter): give the generated adapter's CLI child its input as a file - #233

Open
iperev wants to merge 1 commit into
chain/tx-cli-stdinfrom
chain/txa-adapter-stdin
Open

iperev wants to merge 1 commit into
chain/tx-cli-stdinfrom
chain/txa-adapter-stdin

Conversation

@iperev

@iperev iperev commented Oct 24, 2026

Copy link
Copy Markdown
Contributor

fix(adapter): give the generated adapter's CLI child its input as a file

Part of the UI-DESIGN-01 chain, stacked on it. The change itself is a consumer-facing defect fix, not UI work.

The fix ships in the release after 0.17.0. The 0.17.0 change record is closed and is not edited here.

Why

The TypeScript adapter that json-report-cli-adapter-source generates runs the Proofkit CLI with spawnSync(binaryPath, args, {cwd, env, input, maxBuffer}) and no timeout. The previous PR in this chain showed the mechanism, with C and Go reproducers:

  • On Unix-like systems, Node gives the child one end of a Unix-domain socket pair for the input option. It writes the input, calls shutdown(SHUT_WR) and keeps its end open until the child exits.
  • On macOS, a child blocked in read(2) on that socket is sometimes not woken by the shutdown.

So in the adapter, a call with input can block forever. The defect predates 0.16.0, which ships the same adapter source bytes.

Reproduction on darwin/arm64 with Node 26.11.1:

  • The adapter was the one the CLI generates, unchanged.
  • The CLI ran on 59 captured spec-proof-bundle-admission inputs; 58 of them are 514 to 585 KB.
  • An external watchdog counted a call as a stall after 5 s and ended it by killing only that call's CLI child.
Adapter Calls Stalls
parent commit (sha256:e9089d40…, as in 0.16.0) 590 + 2950 2 + 7
this PR (sha256:e782b2cb…) 2950 0
a variant that removes the input only when the call ends (sha256:b93c6917…) 2950 + 2950 0 + 0
  • Every stalled CLI was sleeping with a Unix socket as fd 0. The one held for 60 s stayed blocked for all 60 s.
  • No call that returned took more than 1.2 s in any of these runs, so the 5 s bound did not cut off slow calls.
  • After the runs of this PR's adapter, its private temporary directory held no input file.

What changes

The generated adapter (internal/command/jsonreportcliadaptersource):

  • Calls with input:
    • The adapter writes the stable JSON input to a file created with O_EXCL and mode 0600, in a fresh mkdtemp directory (mode 0700) under os.tmpdir() of the calling process. It opens the file read-only and passes that descriptor as the child's stdio[0].
    • Except on Windows, it then removes the file and the directory by path (rmSync of the file, then rmdirSync of the directory) before the CLI starts. The CLI reads a file that no longer has a name. A signal that ends the calling process during the call leaves no input on disk.
    • When the call returns or throws, the descriptor is closed, and whatever has not yet been removed is removed:
      • on Windows, where an open file can keep its name until it is closed;
      • after a throw before the early removal;
      • after a failed early removal, which still fails the call.
    • The removal needs no descriptor of its own, so it also works when the process has no free descriptor left. Transport errors are thrown through formatProofkitCliError.
  • Calls without input (inputMode: "none") give the child the null device instead of a shut-down socket. The adapter passes no --input flag for these calls, so the CLI never reads stdin there; the change removes the same mechanism from this path too.
  • Unchanged:
    • cwd, env and maxBuffer are passed as before.
    • stdout, stderr, status and the errors that spawnSync returns are handled as before.
    • The exported symbols and types are unchanged, and no timeout is added.
  • Changed errors. Argument errors that spawnSync throws itself, for example a negative maxBuffer or a NUL byte in binaryPath, cwd or an env value, now go through formatProofkitCliError like the adapter's other errors.
    • They are plain Errors with its redacted, bounded message, without Node's error class or code.
    • Node quotes the offending value in some of these messages, so a secret-shaped env value is now redacted instead of shown.
  • Choices:
    • The file location follows the adapter process's own TMPDIR, not options.env, because the parent writes and removes the file and the child gets only a descriptor.
    • A shared sticky /tmp is safe: the directory name is unpredictable, its mode is 0700, and O_EXCL refuses a pre-existing file or symbolic link.

Tests:

  • Source check. The template's single spawnSync call passes exactly cwd, env, maxBuffer and a stdin descriptor, with no input member. The input file is removed by path right after it is opened, except on Windows.
  • Semantics test. It runs the generated TypeScript under Node with a fake CLI and probes what the child gets as stdin:
    • With input. A regular file of mode 0600 with no remaining link, holding exactly the stable JSON (size and SHA-256). When it is opened, it is alone in a mode 0700 directory, and that directory is gone before the child runs.
    • The Windows branch. It runs by reporting win32 as the platform; Windows itself is not tested. The file keeps its name until the call returns, and is then removed.
    • Without input. The null device, although the harness's own standard input is a pipe, with no --input flag and no directory.
    • Simulated failures. A failure of the read-only open, of either removal call, or of the end-of-call removal on the Windows branch each makes the call throw and leaves no descriptor open. Only the failed end-of-call removal leaves the directory.
  • Further paths covered by the semantics test:
    • a child that exits without reading 2 MiB of input, whose own diagnostic is reported;
    • ENOBUFS under maxBuffer;
    • a negative maxBuffer (a plain Error, no code);
    • a secret-shaped env value (redacted);
    • a missing binary, a cyclic input and a missing temporary directory;
    • an empty temporary root at the end.

Records, each re-pinned from tool or test output:

  • The source SHA-256 pin of the generated adapter.
  • The native-source digest of the command's output contract in proofkit/cli-contract.v2.json, the generated contract projections that follow from it, and the public CLI ABI hash.
  • scripts/generated-starter-structure.test.mjs declares five exact byte deltas for the three adapter bundle rows against the frozen predecessor streams: the imports, the transport, the source digest, and the pretty and compact line count. Each delta has refusal checks. The frozen corpus is unchanged.

README.md states:

  • where a call with input writes it, and that the caller needs a writable temporary directory;
  • when the input is removed, on Windows and elsewhere;
  • when it can remain on disk.

Other sites checked; none uses the mechanism:

  • the Python launcher: os.execve on POSIX, and subprocess.run without input otherwise;
  • the npm sh launcher, which execs the binary;
  • shipped docs, examples and starter output;
  • every other product Go file.

Contract identity and compatibility

Contract identity. Under the rule in docs/proofkit-contract-map.md, contractId stays proofkit.json-report-cli-adapter-source.output.v1 and generatorId stays …typescript.v2:

  • The record's shape and constants are unchanged; source, sourceSha256 and lineCount take new values of unchanged types.
  • Neither the CLI contract nor the bound requirement statements (REQ-PROOFKIT-PACKAGE-002, REQ-PROOFKIT-QUALITY-004) specify how the adapter feeds the CLI's stdin.
  • This is a defect correction, and no requirement text changes.

Compatibility against 0.16.0: not breaking.

  • The differential. Parent and fixed adapters were compared on one binary built from this commit. All 122 cases are identical: status, stdout, stderr, parsed value and thrown message, across the 59 inputs in both layouts, no-input, text and --output calls.
  • The intended differences:
    1. A call that stalled on macOS returns.
    2. A CLI that refuses before reading a large input: on an unknown flag with 4 MiB, the call now reports the CLI's diagnostic instead of spawnSync … EPIPE.
    3. A no-input child's fd 0 is the null device.
    4. A call with input needs a writable temporary directory of the calling process.
      • The input is written there to a 0600 file in a 0700 directory. Except on Windows, the file is removed before the CLI starts. On Windows, which is not a supported platform and does not apply these modes, it is removed when the call returns or throws.
      • The input can remain on disk only if the calling process ends before that removal, or if the removal fails; a failed removal fails the call. The 0.16.0 adapter never put input on disk.
    5. Argument errors that spawnSync throws itself are now plain Errors. Examples are a negative or non-numeric maxBuffer, or a NUL byte in binaryPath, cwd or an env value.
      • The message is the redacted, bounded one from formatProofkitCliError.
      • They lose Node's class (RangeError, TypeError) and code (ERR_OUT_OF_RANGE, ERR_INVALID_ARG_TYPE, ERR_INVALID_ARG_VALUE).
      • The redaction is intended: for a NUL byte in an env value, 0.16.0 threw Node's message with the value in it, secret-shaped or not.
  • Items 4 and 5 are the consumer-visible changes for the release owner to weigh when the next change record declares this one.

Suggested entry for the change record of the release after 0.17.0:

  • changeId: proofkit.json-report-cli-adapter-source.stdin-file
  • summary: "The TypeScript adapter that json-report-cli-adapter-source generates no longer gives the CLI its JSON input through spawnSync's input socket, on which a macOS child could stay blocked forever. A call with input writes the input to a file created with mode 0600 in a fresh mode 0700 directory under the calling process's temporary directory and gives the CLI that file, opened read-only, as its standard input; the caller therefore needs a writable temporary directory. Except on Windows, the file and directory are removed before the CLI starts; on Windows, which is not a supported platform and does not apply these modes, they are removed when the call returns or throws. The input can remain on disk only if the calling process ends before that removal or the removal fails, and a failed removal fails the call. A call without input gives the child the null device. A CLI that refuses before reading a large input now reports its own diagnostic instead of an EPIPE error. Argument errors that spawnSync throws itself are now plain Error objects with the adapter's redacted, bounded diagnostic, without Node's error class or code, so a secret-shaped env value in such a message is redacted. The generated source bytes, sourceSha256 and lineCount change; the record shape, generatorId and contractId are unchanged."
  • Proposed class: compatible, a defect correction. Items 4 and 5 above are the points to weigh.

Proof

Proof: A local npm run check sequence on darwin/arm64 passed (exit 0) on tree a17845a6e8e2 with Node 26.11.1, npm 12.2.0, CPython 3.15.0 and Go 1.27.3, with no Go test verdict replayed from the test cache: 561/561 runtime browser cases in Chromium, Firefox and WebKit with zero retries. One recorded deviation: the host firewall blocks Go-built binaries from the network, so go:vulncheck ran against a vulnerability-database snapshot fetched with curl (modified 2026-10-21T00:27:29Z): no vulnerabilities.

Narrow evidence on darwin/arm64 with Node 26.11.1 (CI's version; the host default is 26.12.0):

  • Reproducer: 9 stalls in 3540 calls with the parent adapter, and 0 in 2950 with this PR's adapter.
  • Signals. SIGTERM, SIGINT or SIGHUP to a consumer during a call leaves no input file. The same happens with the parent adapter, and a variant that removes the input only when the call ends leaves one each time.
  • Descriptor limit. With 1 to 8 free descriptors under ulimit -n 64, a call leaves no descriptor open and no input file.
  • Mutants, each refused:
    • restoring spawnSync's input, directly or through an alias;
    • an inert input: key;
    • the stdin pipe, or the inherited stdin, for no-input calls;
    • no removal;
    • removal only when the call ends;
    • early removal on Windows too;
    • cleanup only around the spawn or only after the open;
    • a swallowed removal failure, early or at the end;
    • no retry after a failed early removal;
    • an unclosed descriptor after a failed removal;
    • the recursive rmSync;
    • file mode 0644;
    • no O_EXCL;
    • a fixed 0755 directory;
    • maxBuffer or env dropped;
    • the parent's whole template.
  • Go: go test -p 2 -count=1 ./... passes (148 packages).
  • Gates: npm run command-contract:check, source-hygiene, text-policy and mermaid:check pass.
  • Node: node --test scripts/generated-starter-structure.test.mjs passes 6/6.
  • Types: the generated adapter type-checks with tsc --strict, and with --exactOptionalPropertyTypes, as before.
  • Whitespace: git diff --check over the range is clean.

Non-claims

  • The kernel code path of the lost wake-up is not identified. The claim is the behavior observed on macOS 15.8.1 on an Apple M1 under the recorded loads. 0 stalls in 2950 calls is statistical evidence; the structural argument is that a regular file ends at its size and has no peer.
  • Confidentiality of the input on disk:
    • The input is the caller's own JSON, already in memory.
    • Except on Windows, it is on disk only between its creation and its removal before the CLI starts. It stays there if the calling process ends in that window or the removal fails.
    • On Windows, it is on disk while the CLI runs, and stays there if the calling process ends during the call.
    • While on disk, it is protected only by file modes, and on Windows not even by those. Same-user, privileged and forensic access are not defended: removal unlinks and does not scrub, and snapshots, backups and swap may keep it.
    • A temporary root in which another user can rename entries is not defended.
  • The adapter still has no timeout, so a child that never exits blocks the call. An optional timeout is left to the owner.
  • Windows is not a supported or tested target. Its branch is tested on macOS by reporting win32 as the platform, not on Windows.
  • The release change record is not updated. It needs an entry for this change in the release after 0.17.0; one is suggested above.

The TypeScript adapter that json-report-cli-adapter-source generates ran
the CLI with spawnSync(binaryPath, args, {cwd, env, input, maxBuffer}) and
no timeout. On Unix-like systems, Node gives the child one end of a
Unix-domain socket pair for the input option, writes the input, shuts down
writing and keeps its end open until the child exits. On macOS, a child
blocked in read(2) on that socket is sometimes not woken by the shutdown,
so an adapter call that passed input could block forever. With the adapter
generated by the parent commit, Node 26.11.1 and the CLI on 59 captured
spec-proof-bundle-admission inputs (58 of them 514 to 585 KB as stable
JSON), 9 of 3540 calls on darwin/arm64 did not return within 5 seconds.
The one held for 60 seconds stayed blocked, with a Unix socket as its
standard input. The adapter generated by this commit returned in all of
2950 such calls.

- The generated runProofkitCommand writes the stable JSON input to a file
  created with O_EXCL and mode 0600 in a fresh mkdtemp directory under
  os.tmpdir() of the calling process, opens it read-only and gives that
  descriptor to spawnSync as stdio[0]. Except on Windows, it then removes
  the file and the directory by path (rmSync of the file, rmdirSync of
  the directory) before the CLI starts, so the CLI reads a file that no
  longer has a name, and a signal that ends the calling process during
  the call leaves no input on disk. When the call returns or throws, the
  descriptor is closed and whatever is not yet removed is removed: on
  Windows, where an open file can keep its name until it is closed, after
  a throw before the early removal, and after a failed early removal. The
  removal needs no descriptor of its own, and a failed removal fails the
  call. Errors of this transport are thrown through
  formatProofkitCliError, as the errors that spawnSync returns already
  were.
- A call without input (inputMode "none") gives the child the null device
  instead of a shut-down socket. The adapter passes no --input flag for
  such a call, so the CLI does not read its standard input.
- cwd, env and maxBuffer are passed as before, and stdout, stderr, status
  and the errors that spawnSync returns are handled as before. Argument
  errors that spawnSync throws itself, such as a negative maxBuffer or a
  NUL byte in binaryPath, cwd or an env value, are now thrown as plain
  Errors carrying formatProofkitCliError's redacted, bounded message,
  without Node's error class or code. A secret-shaped env value that
  Node's message quotes is therefore redacted, where it was shown before.
  A child that exits without reading a large input no longer makes the
  call throw EPIPE; the call reports the child's status and diagnostic
  instead.
- The generated source is 1189 lines with digest
  sha256:e782b2cb6e17f1671a43418cf224a566365a4ebe90730a40699adbffbd1b0ac4.
  The bundle keeps its record shape, generatorId
  proofkit.json-report-cli-adapter-source.typescript.v2 and contractId
  proofkit.json-report-cli-adapter-source.output.v1. Neither the CLI
  contract nor a bound requirement statement specifies how the adapter
  feeds the CLI's standard input, so under the contract-identity rule of
  docs/proofkit-contract-map.md this is a defect correction.
- proofkit/cli-contract.v2.json re-pins the native-source digest of the
  command's output contract. The generated contract projections and the
  public CLI ABI hash follow from that digest.
- scripts/generated-starter-structure.test.mjs declares the byte deltas of
  the three adapter bundle rows from the frozen predecessor streams: the
  imports, the transport, the source digest and the pretty and compact
  line count. The corpus is unchanged.
- A source check requires that the single spawnSync call passes exactly
  cwd, env, maxBuffer and a stdin descriptor, with no input member, and
  that the input file is removed by path right after it is opened, except
  on Windows. The generated-adapter semantics test probes the child's
  standard input: a regular file of mode 0600 with no remaining link that
  holds exactly the stable JSON, alone in a mode 0700 directory when it is
  opened and gone before the child runs, or the null device for a call
  without input, although the harness's own standard input is a pipe.
  The Windows branch, run by reporting win32 as the platform, keeps the
  file under its name until the call returns. Simulated failures of the
  read-only open, of either removal call and of the end-of-call removal
  make the call throw and leave no descriptor open; only the failed
  end-of-call removal leaves the directory. The test also covers an early
  refusal, maxBuffer, a negative maxBuffer, a secret-shaped env value, a
  missing binary and a missing temporary directory.
- README.md states where a call with input writes it, that the caller
  needs a writable temporary directory, when the input is removed on
  Windows and elsewhere, and when it can remain on disk.

No timeout is added.

Proof on this tree:
A local npm run check sequence on darwin/arm64 passed (exit 0) on tree
a17845a with Node 26.11.1, npm 12.2.0, CPython 3.15.0 and Go 1.27.3,
with no Go test verdict replayed from the test cache: 561/561 runtime
browser cases in Chromium, Firefox and WebKit with zero retries. One
recorded deviation: the host firewall blocks Go-built binaries from the
network, so go:vulncheck ran against a vulnerability-database snapshot
fetched with curl (modified 2026-10-21T00:27:29Z): no vulnerabilities.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

1 participant