Skip to content

Commit cc11c00

Browse files
committed
fix: preserve argc result output delivery
1 parent 81b72d1 commit cc11c00

10 files changed

Lines changed: 91 additions & 19 deletions

File tree

‎docs/spec-7.0-behavior.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -246,6 +246,11 @@ Scenario: multiline string field → block scalar
246246
Scenario: handler stdout writes do not pollute the result
247247
Given a handler that console.log("debug") then returns { ok: true }
248248
Then stdout = "ok: true\n" (the console.log went to stderr)
249+
250+
Scenario: the result write respects downstream lifecycle
251+
Given a handler returns a result larger than the stdout buffer
252+
Then argc waits for the result write to complete before returning
253+
And an EPIPE from a downstream reader that closes early is a successful terminal path
249254
```
250255

251256
---

‎package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "argc",
3-
"version": "7.7.0",
3+
"version": "7.7.1",
44
"type": "module",
55
"types": "./dist/index.d.ts",
66
"exports": {

‎skills/argc/SKILL.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ Rendered layout:
3333

3434
```
3535
templates/main.ts -> src/main.ts
36-
templates/main.test.ts -> src/main.test.ts
36+
templates/main.test.ts.tpl -> src/main.test.ts
3737
templates/package.json -> package.json
3838
templates/tsconfig.json -> tsconfig.json
3939
templates/ci.yml -> .github/workflows/ci.yml

‎skills/argc/references/output.md‎

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -67,15 +67,15 @@ For stdout _summaries_, prefer YAML:
6767
Raw data is the exception, not the default: expose it behind `--json` so the agent
6868
can pipe it through `jq`, and keep the default human/agent-facing path as a summary.
6969

70-
## Serializing: use the `yaml` library, not `Bun.YAML`
70+
## Serialization
7171

72-
```typescript
73-
import { stringify } from 'yaml' // npm: yaml (eemeli/yaml), the js-yaml successor
72+
Return the result and let argc own stdout:
7473

75-
process.stdout.write(stringify(value))
74+
```typescript
75+
return value
7676
```
7777

78-
Why not `Bun.YAML.stringify`: its native serializer never emits `|` literal-block
78+
argc uses the `yaml` library rather than `Bun.YAML.stringify`: Bun's native serializer never emits `|` literal-block
7979
scalars — multi-line strings come out as double-quoted with escaped `\n`
8080
(`preview: "<div>\n ..."`). That is intentional and test-locked in Bun through
8181
1.4, not a version gap that will close (the `|`/`>` support in Bun's YAML docs is
@@ -189,18 +189,17 @@ async fetch({ input }) {
189189
await Bun.write(path, JSON.stringify(records)) // persist bulk to state dir
190190

191191
if (input.json) { // --json: raw to the pipe
192-
process.stdout.write(JSON.stringify(records))
193-
return
192+
return JSON.stringify(records)
194193
}
195194

196-
process.stdout.write(stringify({ // default: summary to stdout
195+
return { // argc serializes this summary to stdout
197196
records: records.length,
198197
written: path,
199198
$hints: [
200199
`Records at ${path} — slice with: jq '.[0:10]' ${path}`,
201200
'Re-run with --json to stream raw records to a pipe',
202201
],
203-
}))
202+
}
204203
}
205204
```
206205

‎skills/argc/scripts/argc-start.sh‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@ render() {
8585
}
8686

8787
render main.ts src/main.ts
88-
render main.test.ts src/main.test.ts
88+
render main.test.ts.tpl src/main.test.ts
8989
render package.json package.json
9090
render tsconfig.json tsconfig.json
9191
render ci.yml .github/workflows/ci.yml
Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,7 @@
11
import { expect, test } from 'bun:test'
22
import { join } from 'node:path'
33

4-
// Run the scaffolded entry in place. This file is copied verbatim into a new
5-
// project, so it must not reach outside that project's own src/ — an earlier
6-
// version assembled a throwaway project three directories up and symlinked its
7-
// root in as `argc`, which is how argc verifies its own template, not how a
8-
// downstream CLI tests itself.
4+
// Exercise the same entry point and dependency graph users install.
95
const ENTRY = join(import.meta.dir, 'main.ts')
106

117
async function run(...args: string[]) {

‎src/cli.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import {
3232
formatRuntimeError,
3333
renderError,
3434
renderResult,
35+
writeOutput,
3536
type ErrorEnvelope,
3637
type ErrorIssue,
3738
withStdoutRerouted,
@@ -161,7 +162,7 @@ export class CLI<
161162
runOptions.handlers as Record<string, unknown>,
162163
parsed.raw,
163164
)
164-
process.stdout.write(renderResult(result))
165+
await writeOutput(process.stdout, renderResult(result))
165166
}
166167

167168
private async runBuiltin(

‎src/render.test.ts‎

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { describe, expect, test } from 'bun:test'
2+
import { Writable } from 'node:stream'
3+
4+
import { writeOutput } from './render'
5+
6+
describe('writeOutput', () => {
7+
test('waits until the stream accepts the complete result', async () => {
8+
let completed = false
9+
const stream = new Writable({
10+
write(_chunk, _encoding, callback) {
11+
setTimeout(() => {
12+
completed = true
13+
callback()
14+
}, 5)
15+
},
16+
})
17+
18+
await writeOutput(stream, 'result')
19+
20+
expect(completed).toBe(true)
21+
})
22+
23+
test('treats a downstream EPIPE as a completed write', async () => {
24+
const stream = new Writable({
25+
write(_chunk, _encoding, callback) {
26+
const error = Object.assign(new Error('closed'), { code: 'EPIPE' })
27+
callback(error)
28+
},
29+
})
30+
31+
await expect(writeOutput(stream, 'result')).resolves.toBeUndefined()
32+
})
33+
})

‎src/render.ts‎

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import type { Writable } from 'node:stream'
2+
13
import { stringify } from 'yaml'
24

35
import { colorizeError } from './markup'
@@ -121,6 +123,38 @@ export function renderError(envelope: ErrorEnvelope): string {
121123
return colorizeError(stringify(normalizeValue(envelope), { lineWidth: 0 }))
122124
}
123125

126+
const brokenPipes = new WeakSet<Writable>()
127+
128+
export function writeOutput(stream: Writable, value: string): Promise<void> {
129+
if (value === '' || brokenPipes.has(stream)) return Promise.resolve()
130+
131+
return new Promise((resolve, reject) => {
132+
let settled = false
133+
const finish = (error?: Error | null) => {
134+
if (settled) return
135+
settled = true
136+
stream.off('error', finish)
137+
138+
if (error && 'code' in error && error.code === 'EPIPE') {
139+
// A downstream reader may intentionally close early; the producer has
140+
// completed its contract once the pipe stops accepting output.
141+
brokenPipes.add(stream)
142+
stream.once('error', () => {})
143+
resolve()
144+
return
145+
}
146+
if (error) {
147+
reject(error)
148+
return
149+
}
150+
resolve()
151+
}
152+
153+
stream.once('error', finish)
154+
stream.write(value, finish)
155+
})
156+
}
157+
124158
export async function withStdoutRerouted<T>(fn: () => Promise<T>): Promise<T> {
125159
const originalWrite = process.stdout.write
126160
const originalConsoleLog = console.log

‎src/v7.test.ts‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,8 +53,12 @@ async function capture(
5353
envEntries.map(([key]) => [key, process.env[key]] as const),
5454
)
5555

56-
process.stdout.write = ((chunk: string | Uint8Array) => {
56+
process.stdout.write = ((
57+
chunk: string | Uint8Array,
58+
callback?: (error?: Error | null) => void,
59+
) => {
5760
stdout += String(chunk)
61+
callback?.()
5862
return true
5963
}) as typeof process.stdout.write
6064
process.stderr.write = ((chunk: string | Uint8Array) => {

0 commit comments

Comments
 (0)