Skip to content

Commit 6ac680f

Browse files
committed
fix(dlx): don't mislabel ambiguous Coana failures as launcher failures
Bugbot flagged that with stdio:'inherit' the spawn rejection carries no captured output, so coanaBannerSeen is always false and a Coana process that started then died by signal / exit >= 128 (e.g. OOM, exit 137) was wrongly reported as "the launcher exited before Coana started". Empirically, `coana manifest gradle` writes all output to stdout and never prints the "Coana CLI version" banner, so banner-based detection never worked for the manifest / reachability (inherit) paths anyway. Only a spawn-level error (a string `code` like ENOENT) definitively proves the launcher never started. - buildDlxErrorResult now claims a launch failure ONLY for a spawn-level error; signals and non-zero exits get neutral wording ("Coana failed to run via the package manager (exit code N)") since we cannot tell launcher-vs-Coana apart without captured output. - Soften the fallback warning to drop the "before Coana started" claim (it fires on the ambiguous >= 128 / signal cases too). The npm-install + node fallback gating is unchanged (pre-existing #1327 behavior): it still retries on spawn errors / signals / exit >= 128. Fully suppressing a retry when Coana actually ran would require capturing the launcher output, which conflicts with live streaming (and the reachability spinner); left as a possible follow-up. Update tests for the definitive (ENOENT) vs ambiguous (signal / >= 128) split.
1 parent d3d3b8e commit 6ac680f

2 files changed

Lines changed: 42 additions & 13 deletions

File tree

src/utils/dlx.mts

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -502,7 +502,7 @@ export async function spawnCoanaDlx(
502502
}
503503

504504
logger.warn(
505-
'Coana dlx invocation failed before Coana started; falling back to `npm install` + `node`.',
505+
'Coana dlx invocation failed; retrying via `npm install` + `node`.',
506506
)
507507

508508
const fallbackResult = await spawnCoanaViaNpmInstall(
@@ -618,14 +618,18 @@ function buildDlxErrorResult(e: unknown): CResult<string> {
618618
// Be honest about WHERE the failure happened. On the dlx path the spawned
619619
// process is the package-manager launcher (npx / pnpm dlx / yarn dlx), which
620620
// downloads @coana-tech/cli and only then runs it — so a failure may be the
621-
// launcher dying before Coana ever started (e.g. the package failed to
622-
// download), not Coana itself. Asserting "Coana command failed" in that case
623-
// is misleading.
621+
// launcher dying before Coana ever started, not Coana itself. We can only be
622+
// CERTAIN of that for a spawn-level error (a string `code` like ENOENT: the
623+
// launcher binary could not start, so Coana provably never ran). A non-zero
624+
// exit or signal is genuinely ambiguous — Coana may have started, streamed
625+
// output, and then died (e.g. OOM), or the launcher may have failed to fetch
626+
// the package — and with inherited stdio there is no captured output to tell
627+
// them apart, so we must not assert either way.
624628
let message: string
625629
if (coanaBannerSeen(e)) {
626630
message = `Coana command failed${detailSuffix}: ${detail}`
627-
} else if (dlxLauncherFailedBeforeCoana(e)) {
628-
message = `Failed to launch Coana via the package manager${detailSuffix} — the npx/pnpm-dlx/yarn-dlx launcher exited before Coana started (the package may have failed to download): ${detail}`
631+
} else if (typeof (e as any)?.code === 'string') {
632+
message = `Failed to launch Coana via the package manager${detailSuffix} — the npx/pnpm-dlx/yarn-dlx launcher could not start (e.g. it is missing from PATH): ${detail}`
629633
} else {
630634
message = `Coana failed to run via the package manager${detailSuffix}: ${detail}`
631635
}

src/utils/dlx.test.mts

Lines changed: 32 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -355,7 +355,10 @@ describe('utils/dlx', () => {
355355
})
356356

357357
expect(result.ok).toBe(false)
358-
expect(result.message).toContain('Failed to launch Coana')
358+
// exit 249 is ambiguous, so the message stays neutral about launcher-vs-Coana.
359+
expect(result.message).toContain(
360+
'Coana failed to run via the package manager',
361+
)
359362
// No npm install was attempted.
360363
const npmInstallCalls = mockSpawn.mock.calls.filter(
361364
([cmd, args]) => cmd === 'npm' && (args as string[])[0] === 'install',
@@ -396,7 +399,9 @@ describe('utils/dlx', () => {
396399
})
397400

398401
expect(result.ok).toBe(false)
399-
expect(result.message).toContain('Failed to launch Coana')
402+
expect(result.message).toContain(
403+
'Coana failed to run via the package manager',
404+
)
400405
expect(result.message).toContain('npx aborted')
401406
expect(result.message).toContain('npm-install fallback also failed')
402407
expect(result.message).toContain('registry unreachable')
@@ -426,22 +431,42 @@ describe('utils/dlx', () => {
426431
expect(npmInstallCalls).toHaveLength(0)
427432
})
428433

429-
it('reports a launch failure (not a Coana failure) when the launcher dies before Coana starts', async () => {
430-
// npx aborts (exit 249) before Coana boots and there is no Coana banner.
431-
// Disable the fallback so the dlx error itself is surfaced.
434+
it('reports a definitive launch failure for a spawn-level error (the launcher could not start)', async () => {
435+
// ENOENT: the launcher binary (npx) is missing from PATH, so Coana
436+
// provably never ran. Disable the fallback so the dlx error is surfaced.
432437
process.env['SOCKET_CLI_COANA_DISABLE_NPM_FALLBACK'] = '1'
433-
setDlxRejection({ code: 249, stderr: 'npx aborted' })
438+
setDlxRejection({ code: 'ENOENT' })
434439

435440
const result = await spawnCoanaDlx(['manifest', 'gradle', '.'], 'acme', {
436441
coanaVersion: nextVersion(),
437442
})
438443

439444
expect(result.ok).toBe(false)
440445
expect(result.message).toContain('Failed to launch Coana')
441-
expect(result.message).toContain('before Coana started')
446+
expect(result.message).toContain('could not start')
442447
expect(result.message).not.toContain('Coana command failed')
443448
})
444449

450+
it('does NOT claim a launch failure for an ambiguous signal/high exit code (Coana may have started)', async () => {
451+
// exit 137 (OOM-style) is ambiguous: Coana may have started, streamed
452+
// output, and been killed — or the launcher may have failed. The message
453+
// must not assert either way. Disable the fallback so it is surfaced.
454+
process.env['SOCKET_CLI_COANA_DISABLE_NPM_FALLBACK'] = '1'
455+
setDlxRejection({ code: 137 })
456+
457+
const result = await spawnCoanaDlx(['manifest', 'gradle', '.'], 'acme', {
458+
coanaVersion: nextVersion(),
459+
})
460+
461+
expect(result.ok).toBe(false)
462+
expect(result.message).toContain('exit code 137')
463+
expect(result.message).toContain(
464+
'Coana failed to run via the package manager',
465+
)
466+
expect(result.message).not.toContain('Failed to launch Coana')
467+
expect(result.message).not.toContain('before Coana started')
468+
})
469+
445470
it('does NOT fall back when captured stderr shows Coana booted', async () => {
446471
// Coana banner present in stderr → Coana clearly ran, so any subsequent
447472
// failure is a real Coana issue, not a launcher problem.

0 commit comments

Comments
 (0)