Sourced via Codex scan of github/spec-kit and deepseek-ai/deepseek-harness (scan reports in granja/_temp/codex-logs/), then authored by Codex against this repo's exact SKILL.md format/density, calibrated against bastille-jail-provisioning/writing-implementation-plans/tdd. Spot-checked two directly (write-feature-specification, harden-async-lifecycle-code) -- concrete, code-example-backed procedures, not generic advice. From spec-kit: write-feature-specification, clarify-feature-specification, audit-requirements-quality, analyze-spec-plan-task-consistency, converge-implementation-to-spec. From deepseek-harness: harden-async-lifecycle-code, test-real-entry-paths, snapshot-agent-behavior, maintain-decision-records, remove-reasoning-transcript-prose. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
160 lines
7.0 KiB
Markdown
160 lines
7.0 KiB
Markdown
---
|
||
name: harden-async-lifecycle-code
|
||
description: Use when implementing or reviewing asynchronous lifecycle, callback dispatch, subprocess, cancellation, temporary spill, or teardown code where races, orphaned work, exception leakage, secret exposure, or unsafe link cleanup are possible.
|
||
---
|
||
|
||
# Harden Async Lifecycle Code
|
||
|
||
Treat lifecycle correctness as a set of independently observable contracts. Each rule below prevents a distinct shipped failure class; do not collapse them into a single success boolean or a generic cleanup block.
|
||
|
||
## 1. Draw the lifecycle before editing
|
||
|
||
For every owned resource, record:
|
||
|
||
```text
|
||
resource | creator | start signal | completion signal | cancellation request | quiescence signal | disposer
|
||
```
|
||
|
||
Include processes, streams, readers, timers, callbacks, registries, temp files, and background promises. Mark shared signals versus per-operation signals. If ownership or quiescence cannot be named, the design is not ready.
|
||
|
||
Model states explicitly, for example:
|
||
|
||
```text
|
||
created → starting → running → stopping → quiescent → disposed
|
||
↘ failed ─────────────↗
|
||
```
|
||
|
||
Define legal repeated calls. Usually cancellation and disposal are idempotent; starting twice is rejected.
|
||
|
||
## 2. Report orthogonal outcomes independently
|
||
|
||
A process may time out and later exit zero because it handled the termination signal. A request may be aborted and also produce a provider error. Do not nest one fact beneath another:
|
||
|
||
```ts
|
||
type Outcome = {
|
||
timedOut: boolean
|
||
aborted: boolean
|
||
exitCode: number | null
|
||
signal: string | null
|
||
error: Error | null
|
||
}
|
||
```
|
||
|
||
Bad:
|
||
|
||
```ts
|
||
if (exitCode !== 0) return { timedOut }
|
||
return { ok: true } // loses the fact that the deadline cut the run short
|
||
```
|
||
|
||
This rule prevents cut-short work from being reported as a clean success. Define precedence only for a final display label; preserve every underlying fact.
|
||
|
||
## 3. Normalize public async contracts
|
||
|
||
Inventory every way an implementation can signal termination: throw, reject, terminal event, EOF, abort event, or status field. Choose one public contract and normalize at the boundary.
|
||
|
||
Example: provider failures may arrive as throws or error-finish events, while consumer callback defects should remain thrown. Convert only provider-originated forms to the documented terminal result; do not swallow middleware or caller bugs into the same bucket.
|
||
|
||
Test each source form through a real consumer. This prevents callers from guessing whether a caught exception came from the provider, a wrapper, logging, or their own assembly.
|
||
|
||
## 4. Do not mistake shared state for operation completion
|
||
|
||
Global `idle`, `running`, reader `close`, or queue-empty signals may cover several operations. They cannot prove which message completed, whether it started, or which output belongs to it.
|
||
|
||
For an owned operation, define an interval with durable boundaries, such as:
|
||
|
||
```text
|
||
operation receipt persisted → all work attributable to the owned run settled → system quiescent
|
||
```
|
||
|
||
If the API lacks per-operation completion, describe captured output as interval-wide, not causally attributed. Handle the “nothing started, therefore no transition will occur” branch explicitly or the wait can hang forever.
|
||
|
||
This prevents wrapper jobs from reporting completion merely because background work was started or a shared agent later became idle.
|
||
|
||
## 5. Dispose to quiescence
|
||
|
||
Disposal means work has stopped, not that a stop request was issued.
|
||
|
||
Use this order unless resource dependencies require a documented variation:
|
||
|
||
1. mark the owner as disposing so no new work is accepted;
|
||
2. close callback/listener/notification registries;
|
||
3. request cancellation or send termination to children;
|
||
4. await every child’s definitive completion signal;
|
||
5. escalate termination after a bounded grace period when appropriate;
|
||
6. await exit after escalation;
|
||
7. close streams, handles, and files;
|
||
8. remove owned temporary paths;
|
||
9. mark disposed.
|
||
|
||
```ts
|
||
async function dispose(): Promise<void> {
|
||
if (disposePromise) return disposePromise
|
||
disposePromise = (async () => {
|
||
accepting = false
|
||
listeners.clear()
|
||
child.kill('SIGTERM')
|
||
await withTimeout(childDone, graceMs, () => child.kill('SIGKILL'))
|
||
await childDone
|
||
await removeOwnedTemp()
|
||
})()
|
||
return disposePromise
|
||
}
|
||
```
|
||
|
||
Returning after `kill()` creates orphaned processes and late callbacks into torn-down state.
|
||
|
||
## 6. Isolate callback failures
|
||
|
||
Take a stable snapshot if listeners may add/remove listeners during dispatch. Catch per listener so one bad subscriber cannot reject the lifecycle promise or starve later subscribers:
|
||
|
||
```ts
|
||
for (const listener of [...listeners]) {
|
||
try {
|
||
await listener(event)
|
||
} catch (error) {
|
||
logger.error({ error }, 'listener failed')
|
||
}
|
||
}
|
||
```
|
||
|
||
Define whether dispatch is sequential or concurrent. Sequential preserves order; concurrent must collect all settlements. Never leave floating promises.
|
||
|
||
## 7. Scrub child environments
|
||
|
||
Do not pass the ambient environment to an untrusted command or tool output. Build an allowlist where feasible. Otherwise clone and delete keys whose case-insensitive names contain `KEY`, `SECRET`, `TOKEN`, `PASSWORD`, `CREDENTIAL`, or project-specific secret names.
|
||
|
||
Preserve only variables required for execution, such as a controlled `PATH`, locale, and explicitly approved runtime settings. Test that canary secrets are absent from child `env` output and spill artifacts. This prevents harness or CI credentials from leaking through subprocess output.
|
||
|
||
## 8. Create private unpredictable spill paths
|
||
|
||
Create a random private directory with mode `0700`, then files with exclusive creation and mode `0600`:
|
||
|
||
```ts
|
||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'worker-'))
|
||
await fs.chmod(dir, 0o700)
|
||
const handle = await fs.open(path.join(dir, randomUUID()), 'wx', 0o600)
|
||
```
|
||
|
||
Never use a predictable shared filename, open without exclusivity, or depend on a permissive default umask. Those allow pre-created symlink races and cross-user disclosure.
|
||
|
||
## 9. Unlink link-shaped paths safely
|
||
|
||
For a path that may be a symlink or Windows junction, inspect with `lstat`, not `stat`. If it is link-shaped, call `unlink`; do not recursively remove it. `unlink` deletes the link and refuses a real directory. Recursive removal may follow a junction or otherwise endanger its target.
|
||
|
||
Reserve recursive deletion for a path proven to be a real directory created and owned by this operation. Validate the resolved target remains inside the owned private parent before deletion.
|
||
|
||
## 10. Test the failure classes
|
||
|
||
- timeout plus zero exit preserves both facts;
|
||
- cancellation before start does not hang;
|
||
- disposal awaits real process exit and emits no late callback;
|
||
- one throwing listener does not block the next;
|
||
- repeated disposal returns the same settlement;
|
||
- child environment omits canary secrets;
|
||
- spill creation rejects a pre-existing name;
|
||
- cleanup unlinks a symlink/junction without touching its target;
|
||
- provider errors normalize while consumer defects still throw.
|
||
|
||
Use fake clocks only for time nondeterminism; keep the real queue, dispatcher, process wrapper, and cleanup owner whenever possible.
|