Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 97 additions & 0 deletions experiments/issue-150-forged-exit-code.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
#!/usr/bin/env bun
/**
* End-to-end reproduction for issue #150, driven through the real `$ --status`
* CLI and no mocks (and no docker daemon required).
*
* It stores a detached-docker execution record whose container name cannot be
* inspected — the exact window from the incident: the container is gone and the
* host-side watcher has not appended the genuine footer yet — and whose log
* contains the substring `Exit Code: 1` inside ordinary command output.
*
* Before the fix, `$ --status` reported `status executed / exitCode 1`.
* After the fix it keeps reporting `status executing / exitCode` unset, and it
* only reports a terminal exit code once the anchored footer is appended.
*
* Usage: bun experiments/issue-150-forged-exit-code.mjs
* Reference: https://github.com/link-foundation/start/issues/150
*/

import { spawnSync } from 'child_process';
import fs from 'fs';
import os from 'os';
import path from 'path';
import { fileURLToPath } from 'url';

const here = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.join(here, '..');
const cliPath = path.join(repoRoot, 'js/src/bin/cli.js');
const { ExecutionStore, ExecutionRecord } = await import(
path.join(repoRoot, 'js/src/lib/execution-store.js')
);

const appFolder = fs.mkdtempSync(path.join(os.tmpdir(), 'issue-150-'));
const logPath = path.join(appFolder, 'session.log');
const sessionName = `issue150-repro-${process.pid}`;

// The command's own output happens to contain "Exit Code: 1" — here as the tail
// of an unrelated, older session log dumped by `rg -n` into a JSON payload.
const forgedOutput =
'{"type":"item.completed","item":{"aggregated_output":' +
'"40-==================================================\\n' +
'41-Finished: 2026-07-28 20:04:52.316\\n42-Exit Code: 1\\n",' +
'"exit_code":0,"status":"completed"}}\n';
fs.writeFileSync(logPath, forgedOutput);

const store = new ExecutionStore({ appFolder, useLinks: false });
store.save(
new ExecutionRecord({
command: 'solve.mjs --detached',
logPath,
options: {
sessionName,
isolated: 'docker',
isolationMode: 'detached',
},
})
);

function status() {
const result = spawnSync('bun', [cliPath, '--status', sessionName], {
encoding: 'utf8',
env: { ...process.env, START_APP_FOLDER: appFolder },
timeout: 30000,
});
return `${result.stdout || ''}${result.stderr || ''}`;
}

let failures = 0;
function check(label, condition, output) {
console.log(`${condition ? '✅' : '❌'} ${label}`);
if (!condition) {
failures += 1;
console.log(output);
}
}

const beforeFooter = status();
check(
'forged "Exit Code: 1" in the output does not terminate the session',
/status executing/.test(beforeFooter) && !/exitCode 1/.test(beforeFooter),
beforeFooter
);

// Now the genuine footer, exactly as `start` appends it.
fs.appendFileSync(
logPath,
`\n${'='.repeat(50)}\nFinished: 2026-07-30 23:36:20.295\nExit Code: 0\n`
);

const afterFooter = status();
check(
'the anchored footer is honored once it is written',
/status executed/.test(afterFooter) && /exitCode 0/.test(afterFooter),
afterFooter
);

fs.rmSync(appFolder, { recursive: true, force: true });
process.exit(failures === 0 ? 0 : 1);
5 changes: 5 additions & 0 deletions js/.changeset/issue-150-anchored-exit-code-footer.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'start-command': patch
---

Stop `--status` from fabricating a detached session exit code out of the command's own output: the terminal exit code is now read from the anchored three-line footer `start` writes (separator / `Finished:` / `Exit Code:`) in the tail of the log only, and Docker's own `.State.ExitCode` takes precedence over the log text.
98 changes: 85 additions & 13 deletions js/src/lib/status-formatter.js
Original file line number Diff line number Diff line change
Expand Up @@ -175,20 +175,88 @@ function isDetachedSessionAlive(record) {
}
}

/**
* Number of trailing bytes scanned for the terminal log footer. The footer is
* always appended at the very end of the log, so there is no reason to read
* (potentially megabytes of) command output on every `--status` call.
*/
const LOG_TAIL_BYTES = 16 * 1024;

/**
* The three-line terminal footer that `start` itself writes (see
* `createLogFooter()` and `createShellLogFooterSnippet()`):
*
* ==================================================
* Finished: 2026-07-30 23:36:20.295
* Exit Code: 0
*
* The whole block is anchored at line starts so that a bare `Exit Code: N`
* substring emitted by the wrapped command (inside a JSON payload, a quoted
* log excerpt, an `rg` dump, ...) can no longer be mistaken for the footer
* `start` appends itself (issue #150).
*/
const LOG_FOOTER_PATTERN =
/^={10,}[ \t]*\r?\n^Finished:[^\r\n]*\r?\n^Exit Code:[ \t]*(-?\d+)[ \t]*(?![^\r\n])/gm;

/**
* Read the last `bytes` bytes of a file as UTF-8 text.
*
* A partial first line is dropped: the slice can start in the middle of a line,
* and that fragment must not be treated as the beginning of a line by the
* anchored footer pattern.
*
* @param {string} logPath - Path to the log file
* @param {number} [bytes] - Maximum number of trailing bytes to read
* @returns {string|null} Tail content, or null when the file cannot be read
*/
function readLogTail(logPath, bytes = LOG_TAIL_BYTES) {
let fd;
try {
fd = fs.openSync(logPath, 'r');
const size = fs.fstatSync(fd).size;
const length = Math.min(size, bytes);
const buffer = Buffer.alloc(length);
fs.readSync(fd, buffer, 0, length, size - length);
const tail = buffer.toString('utf8');
if (size <= bytes) {
return tail;
}
const firstNewline = tail.indexOf('\n');
return firstNewline === -1 ? '' : tail.slice(firstNewline + 1);
} catch {
return null;
} finally {
if (fd !== undefined) {
try {
fs.closeSync(fd);
} catch {
// ignore
}
}
}
}

/**
* Read the terminal exit code from the anchored footer at the end of a log.
* Returns null when the log has no footer yet (command still running, or the
* host-side watcher has not appended it yet) — never a number parsed out of the
* command's own output.
* @param {string} logPath - Path to the log file
* @returns {number|null}
*/
function readExitCodeFromLog(logPath) {
if (!logPath) {
return null;
}
try {
const content = fs.readFileSync(logPath, 'utf8');
const matches = [...content.matchAll(/Exit Code:\s*(-?\d+)/g)];
if (matches.length === 0) {
return null;
}
return parseInt(matches[matches.length - 1][1], 10);
} catch {
const tail = readLogTail(logPath);
if (tail === null) {
return null;
}
const matches = [...tail.matchAll(LOG_FOOTER_PATTERN)];
if (matches.length === 0) {
return null;
}
return parseInt(matches[matches.length - 1][1], 10);
}

/**
Expand Down Expand Up @@ -280,15 +348,17 @@ function enrichDetachedStatus(record) {
// Otherwise keep the recorded/footer exit code - the command has finished.
} else if (!alive && enriched.status === 'executing') {
// Session ended but record says executing - correct it. Resolve a real exit
// code: prefer the log footer, then the backend's own record (e.g.
// `docker inspect .State.ExitCode`), then `137` when the only evidence left
// is the OOM observation, and only fall back to the `-1` sentinel as a last
// resort when no real code can be obtained (issues #136, #151).
// code: prefer the backend's own record (e.g. `docker inspect
// .State.ExitCode`, which is authoritative and cannot be spoofed by command
// output — issue #150), then the anchored log footer, then `137` when the
// only evidence left is the OOM observation, and only fall back to the `-1`
// sentinel as a last resort when no real code can be obtained (issues #136,
// #151).
enriched.status = 'executed';
if (enriched.exitCode === null || enriched.exitCode === undefined) {
enriched.exitCode =
footerExit ??
backendExitCode(dockerState) ??
footerExit ??
(oomKilled === true ? 137 : -1);
}
if (!enriched.endTime) {
Expand Down Expand Up @@ -638,6 +708,8 @@ module.exports = {
listExecutions,
isDetachedSessionAlive,
enrichDetachedStatus,
readExitCodeFromLog,
readLogTail,
attachCurrentTime,
attachProcessIds,
};
Loading
Loading