Skip to content

[eas-cli] Do not hang in workflow:run when stdin never delivers data - #4261

Open
giaBaoJS wants to merge 1 commit into
expo:mainfrom
giaBaoJS:fix/workflow-run-stdin-hang
Open

[eas-cli] Do not hang in workflow:run when stdin never delivers data#4261
giaBaoJS wants to merge 1 commit into
expo:mainfrom
giaBaoJS:fix/workflow-run-stdin-hang

Conversation

@giaBaoJS

Copy link
Copy Markdown

Why

Fixes #3774 and #3164.

eas workflow:run prints Using workflow file from ... and then hangs forever on a CI agent. No workflow run is ever created, and the pipeline eventually times out. Five people have confirmed it on #3164, the reported environment is Azure Pipelines. The community workarounds are </dev/null or echo '{}' |.

@sjchmiela guessed at the cause on #3164:

Oh interesting… I guess this might happen if Node thinks the Azure pipeline run environment is a TTY whereas it's not?

and

Not sure what to do better here…

It is the other way around. maybeReadStdinAsync() in packages/eas-cli/src/commandUtils/workflow/utils.ts short-circuits only when process.stdin.isTTY:

if (process.stdin.isTTY) {
  return null;
}

return await new Promise((resolve, reject) => {
  ...
  process.stdin.on('end', () => {
    resolve(data.trim() || null);
  });
  ...
});

On a CI agent stdin is an open pipe. It is not a TTY, so the short-circuit does not fire, and nobody ever writes to it or closes it, so 'end' never fires either. The promise never settles. workflow/run.ts awaits it at the top of the command, before any flag is looked at, so the command stops there. eas build is unaffected because it never reads stdin.

How

There is no way to ask a pipe whether anyone will ever write to it, so the only thing that can be done is to wait a bounded amount of time for the first byte:

  • Wait up to 1s for the first chunk of piped input. If nothing arrives, resolve null and carry on.
  • Once any data has arrived, clear that deadline and wait for 'end' with no timeout. A writer that has started talking to us is never cut off mid-payload, so cat inputs.json | eas workflow:run keeps working no matter how big or slow the payload is. Truncating input silently would be worse than hanging.
  • Also short-circuit when stdin has already ended or been destroyed. Attaching an 'end' listener to such a stream would wait forever too.
  • Unreference the stdin handle once we stop reading. An open pipe that is still being read keeps the libuv loop alive, so without this the command would resolve the promise and then still hang at exit. stdin.pause() does not work here, because a stream with a 'readable' listener already has flowing === false and pause() returns early without emitting the 'pause' event that Node's own stdin handling listens for.

This is suggestions 3 and 4 from #3774. I did not implement suggestion 1 (skip the read when --non-interactive is set), because piping JSON in is documented at the top of workflow/run.ts as a first class input source and is independent of interactivity. echo '{}' | eas workflow:run --non-interactive is exactly what people are running today as the workaround, and skipping stdin under --non-interactive would break it.

The trade off worth flagging: a producer that takes longer than 1s to emit its first byte, say curl ... | eas workflow:run, would now be treated as "no stdin". 1s is the value suggested in #3774 and it is generous for anything already buffered, but it is a judgement call and easy to raise if you would rather have it higher. Missing required inputs still fail loudly rather than silently, since they are validated after this point.

Test Plan

New tests in packages/eas-cli/src/commandUtils/workflow/__tests__/utils-test.ts swap process.stdin for a PassThrough and race the call against a sentinel, so a promise that never settles fails with a readable assertion instead of a bare timeout.

cd packages/eas-cli && yarn jest src/commandUtils/workflow/__tests__/utils-test.ts

PASS src/commandUtils/workflow/__tests__/utils-test.ts
  workflow utils
    ✓ shows the display name for the current step while keying logs by step id (1 ms)
  maybeReadStdinAsync
    ✓ returns null when stdin is a TTY (1 ms)
    ✓ returns null when stdin is an open pipe that never delivers data nor ends (1001 ms)
    ✓ returns null when stdin has already been destroyed
    ✓ reads JSON piped into stdin (1 ms)
    ✓ returns null for a pipe that closes without writing anything (1 ms)
    ✓ waits for the whole payload when the writer is slow to finish (2002 ms)

Tests:       7 passed, 7 total

Reverting only utils.ts and keeping the tests, the two hang cases go red for the right reason and the four behaviour cases stay green, which is what shows the change is not silently altering how real piped input is handled:

  maybeReadStdinAsync
    ✓ returns null when stdin is a TTY
    ✕ returns null when stdin is an open pipe that never delivers data nor ends (5002 ms)
    ✕ returns null when stdin has already been destroyed (5002 ms)
    ✓ reads JSON piped into stdin (2 ms)
    ✓ returns null for a pipe that closes without writing anything (1 ms)
    ✓ waits for the whole payload when the writer is slow to finish (2002 ms)

  ● maybeReadStdinAsync › returns null when stdin is an open pipe that never delivers data nor ends

    expect(received).resolves.toBeNull()

    Received: "maybeReadStdinAsync() did not settle"

I also ran the built maybeReadStdinAsync in a real process against a real fd, since the unit tests cannot cover process exit. The script calls the function and prints the result, the harness reports when the process exits and kills it at 15s:

=== CASE A: stdin is an open pipe nobody ever writes to (the reported CI case) ===
   on main:      (no output)                        process killed at 15053ms
   with the fix: result=null after 1001ms           process exited rc=0 after 1528ms

=== CASE B: echo '{"a":1}' | ===
   result="{\"a\":1}" after 1ms                     process exited rc=0 after 452ms

=== CASE C: < /dev/null ===
   result=null after 1ms                            process exited rc=0 after 508ms

=== CASE E: < inputs.json ===
   result="{\"from\":\"file\"}" after 1ms           process exited rc=0 after 522ms

=== CASE F: cat 200KB.json |  (multi chunk) ===
   result=<full 200KB payload> after 1ms            process exited rc=0 after 529ms

=== CASE D: slow producer, first byte only after 3s ===
   result=null after 1003ms                         process exited rc=0 after 3426ms

Case D is the trade off described above, shown rather than hidden.

Rest of the package: cd packages/eas-cli && yarn jest

Test Suites: 2 failed, 286 passed, 288 total
Tests:       4 failed, 4 skipped, 2444 passed, 2452 total

The two failing suites are src/observe/__tests__/formatEvents.test.ts and src/observe/__tests__/formatCustomEvents.test.ts, and they fail identically on a clean checkout of main (4 failed, 2438 passed, 2446 total). It is a date locale mismatch, 1 Jan 2025 vs the expected Jan 1, 2025, unrelated to this change.

yarn lint clean, yarn fmt:check clean, tsc --noEmit clean, yarn lint-changelog reports CHANGELOG.md is valid.

@github-actions

Copy link
Copy Markdown

Subscribed to pull request

File Patterns Mentions
packages/eas-cli/** @douglowder

Generated by CodeMention

Warning: The preamble and epilogue options in commentConfiguration are deprecated. Use template instead.

`maybeReadStdinAsync()` only short-circuited on `process.stdin.isTTY`. On a CI
agent stdin is an open pipe: not a TTY, and it never emits 'end' either, so the
promise never settled and `eas workflow:run` waited forever without creating a
run.

Wait a short grace period for the first chunk of piped input and fall back to
`null` when nothing arrives. Once any data has arrived we wait for 'end' with no
deadline, so piping a large or slowly written payload still works. Also
short-circuit when stdin has already ended or been destroyed, and unreference the
stdin handle once we stop reading so it cannot keep the process alive.

Fixes expo#3164
Fixes expo#3774
@giaBaoJS
giaBaoJS force-pushed the fix/workflow-run-stdin-hang branch from 200d975 to 7aee20b Compare August 25, 2026 02:03
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.

eas workflow:run hangs indefinitely on non-TTY CI environments

1 participant