Repository navigation
fix: exit once the action settles and time out the runtime version listing #70
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,85 @@ | ||
| import assert from 'node:assert/strict' | ||
| import { spawn } from 'node:child_process' | ||
| import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' | ||
| import { tmpdir } from 'node:os' | ||
| import path from 'node:path' | ||
| import test from 'node:test' | ||
| import { fileURLToPath } from 'node:url' | ||
| import { build } from 'esbuild' | ||
|
|
||
| const { outputFiles } = await build({ | ||
| entryPoints: [fileURLToPath(new URL('./flush-and-exit.ts', import.meta.url))], | ||
| bundle: true, | ||
| platform: 'node', | ||
| format: 'cjs', | ||
| write: false, | ||
| }) | ||
|
|
||
| const CHUNK = 'a'.repeat(400_000) | ||
|
|
||
| // A pipe holds 64 KiB, so a child writing more than that only keeps its tail | ||
| // when the exit waits for the drain. | ||
| function runChild(t, source) { | ||
| const dir = mkdtempSync(path.join(tmpdir(), 'flush-and-exit-')) | ||
| t.after(() => rmSync(dir, { recursive: true, force: true })) | ||
| writeFileSync(path.join(dir, 'flush-and-exit.cjs'), outputFiles[0].text) | ||
| const entry = path.join(dir, 'child.cjs') | ||
| writeFileSync(entry, source) | ||
| return new Promise((resolve, reject) => { | ||
| const cp = spawn(process.execPath, [entry], { stdio: ['ignore', 'pipe', 'pipe'] }) | ||
| let stdout = '' | ||
| let stderr = '' | ||
| cp.stdout.setEncoding('utf8') | ||
| cp.stderr.setEncoding('utf8') | ||
| cp.stdout.pause() | ||
| cp.stderr.pause() | ||
| setTimeout(() => { | ||
| cp.stdout.resume() | ||
| cp.stderr.resume() | ||
| }, 300) | ||
| cp.stdout.on('data', chunk => { | ||
| stdout += chunk | ||
| }) | ||
| cp.stderr.on('data', chunk => { | ||
| stderr += chunk | ||
| }) | ||
| cp.on('error', reject) | ||
| cp.on('close', (code, signal) => resolve({ stdout, stderr, code, signal })) | ||
| }) | ||
| } | ||
|
|
||
| test('writes queued on both pipes reach the parent before the exit', async t => { | ||
| const { stdout, stderr, code } = await runChild(t, ` | ||
| const { flushAndExit } = require('./flush-and-exit.cjs') | ||
| process.stdout.write(${JSON.stringify(CHUNK)}) | ||
| process.stderr.write(${JSON.stringify(CHUNK)}) | ||
| process.stdout.write('::error::install failed\\n') | ||
| flushAndExit() | ||
| `) | ||
|
|
||
| assert.equal(stdout, `${CHUNK}::error::install failed\n`) | ||
| assert.equal(stderr, CHUNK) | ||
| assert.equal(code, 0) | ||
| }) | ||
|
|
||
| test('keeps the exit code the failure handler set', async t => { | ||
| const { stdout, code } = await runChild(t, ` | ||
| const { flushAndExit } = require('./flush-and-exit.cjs') | ||
| process.exitCode = 1 | ||
| process.stdout.write(${JSON.stringify(CHUNK)}) | ||
| flushAndExit() | ||
| `) | ||
|
|
||
| assert.equal(stdout, CHUNK) | ||
| assert.equal(code, 1) | ||
| }) | ||
|
|
||
| test('exits while a handle keeps the loop alive', async t => { | ||
| const { code } = await runChild(t, ` | ||
| const { flushAndExit } = require('./flush-and-exit.cjs') | ||
| setInterval(() => {}, 1000) | ||
| flushAndExit() | ||
| `) | ||
|
|
||
| assert.equal(code, 0) | ||
| }) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,17 @@ | ||
| /** | ||
| * Exits the process once stdout and stderr have drained. | ||
| * | ||
| * Both streams are pipes under the Actions runner, where writes are | ||
| * asynchronous, so a bare `process.exit()` discards whatever is still queued: | ||
| * the workflow command `setFailed` wrote and the error `console.error` printed | ||
| * reach the runner only after the drain. | ||
| */ | ||
| export function flushAndExit() { | ||
| let pending = 2 | ||
| const exitWhenDrained = () => { | ||
| pending -= 1 | ||
| if (pending === 0) process.exit() | ||
| } | ||
| process.stdout.write('', exitWhenDrained) | ||
| process.stderr.write('', exitWhenDrained) | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -213,18 +213,19 @@ function runPnpmForOutput(binDest: string, args: string[]): Promise<string> { | |
| return new Promise<string>((resolve, reject) => { | ||
| const cp = spawn(pnpmBin, args, { | ||
| stdio: ['ignore', 'pipe', 'inherit'], | ||
| timeout: 60_000, | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. covered in 6ab309b by a timeout surfaces through that same path, because node's spawn |
||
| }) | ||
| let stdout = '' | ||
| cp.stdout.setEncoding('utf8') | ||
| cp.stdout.on('data', chunk => { | ||
| stdout += chunk | ||
| }) | ||
| cp.on('error', reject) | ||
| cp.on('close', code => { | ||
| cp.on('close', (code, signal) => { | ||
| if (code === 0) { | ||
| resolve(stdout) | ||
| } else { | ||
| reject(new Error(`pnpm ${args.join(' ')} exited with code ${code}`)) | ||
| reject(new Error(`pnpm ${args.join(' ')} exited with ${signal ?? `code ${code}`}`)) | ||
| } | ||
| }) | ||
| }) | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,75 @@ | ||
| import assert from 'node:assert/strict' | ||
| import { chmodSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' | ||
| import { createRequire } from 'node:module' | ||
| import { tmpdir } from 'node:os' | ||
| import path from 'node:path' | ||
| import test from 'node:test' | ||
| import { fileURLToPath } from 'node:url' | ||
| import { build } from 'esbuild' | ||
|
|
||
| const { outputFiles } = await build({ | ||
| entryPoints: [fileURLToPath(new URL('./index.ts', import.meta.url))], | ||
| bundle: true, | ||
| platform: 'node', | ||
| format: 'cjs', | ||
| write: false, | ||
| }) | ||
| const bundledModule = { exports: {} } | ||
| new Function('require', 'module', 'exports', outputFiles[0].text)( | ||
| createRequire(import.meta.url), bundledModule, bundledModule.exports, | ||
| ) | ||
| const { getInstalledRuntimeVersions } = bundledModule.exports | ||
|
|
||
| function fakePnpm(t, body) { | ||
| const binDest = mkdtempSync(path.join(tmpdir(), 'pnpm-installed-versions-')) | ||
| t.after(() => rmSync(binDest, { recursive: true, force: true })) | ||
| const bin = path.join(binDest, process.platform === 'win32' ? 'pnpm.exe' : 'pnpm') | ||
| writeFileSync(bin, `#!/bin/sh\n${body}\n`) | ||
| chmodSync(bin, 0o755) | ||
| return binDest | ||
| } | ||
|
|
||
| // The runner reports through stdout too, so the capture has to pass every | ||
| // write along to it. | ||
| function captureStdout(t) { | ||
| const written = [] | ||
| const original = process.stdout.write | ||
| process.stdout.write = function (chunk, ...rest) { | ||
| written.push(String(chunk)) | ||
| return original.call(this, chunk, ...rest) | ||
| } | ||
| t.after(() => { | ||
| process.stdout.write = original | ||
| }) | ||
| return written | ||
| } | ||
|
|
||
| test('reports the signal when the listing is terminated', { skip: process.platform === 'win32' }, async t => { | ||
| const binDest = fakePnpm(t, 'kill -TERM $$\nsleep 5') | ||
| const logged = captureStdout(t) | ||
|
|
||
| const versions = await getInstalledRuntimeVersions(['node'], binDest) | ||
|
|
||
| assert.deepEqual([...versions], []) | ||
| assert.match(logged.join(''), /::warning::Unable to determine the installed runtime versions: pnpm list --global --json --depth 0 exited with SIGTERM/) | ||
| }) | ||
|
|
||
| test('reports the exit code when the listing fails', { skip: process.platform === 'win32' }, async t => { | ||
| const binDest = fakePnpm(t, 'exit 3') | ||
| const logged = captureStdout(t) | ||
|
|
||
| const versions = await getInstalledRuntimeVersions(['node'], binDest) | ||
|
|
||
| assert.deepEqual([...versions], []) | ||
| assert.match(logged.join(''), /exited with code 3/) | ||
| }) | ||
|
|
||
| test('reads the version out of a successful listing', { skip: process.platform === 'win32' }, async t => { | ||
| const listing = JSON.stringify([{ dependencies: { node: { version: '24.9.0' } } }]) | ||
| const binDest = fakePnpm(t, `cat <<'JSON'\n${listing}\nJSON`) | ||
| captureStdout(t) | ||
|
|
||
| const versions = await getInstalledRuntimeVersions(['node'], binDest) | ||
|
|
||
| assert.deepEqual([...versions], [['node', '24.9.0']]) | ||
| }) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
flushAndExitdoes, which its name and implementation already make clear. The repository requires comments not to narrate code; please satisfy that requirement before merging.Context Used: Comments and docs in code are suspicious. Is test coverage not sufficient the reason why a comment was added? Comments should not replace tests. Comments should also not narrate code. Is the code hard to understand? Then it should be refactored to ma... (source)
Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!