DeepSeek-Harness: Defensives Programmieren: Ergebnisberichterstattung, Bereinigung und Anmeldedaten

Nachdem du so viele Plugins geschrieben hast, kann es passieren, dass du auf eine Klasse von Edge-Case-Bugs stößt, die "in Tests nicht reproduziert werden können, aber in dem Moment explodieren, in dem sie live gehen."

Die offiziellen Docs gruppieren sie in "mühsam errungene Regeln für Fehlerkategorien" — jede einzelne ist ein Fehler, der tatsächlich ausgeliefert oder fast ausgeliefert wurde.

Dieser Beitrag behandelt die fünf wichtigsten Muster der defensiven Programmierung: Ergebnisberichterstattung, Dispose-Ruhe, Löschen von Anmeldeinformationen, Link-Löschung und Callback-Isolierung.

In einem Satz: Diese Muster verhindern, dass "ein einfacher Edge Case den gesamten Agenten lahmlegt."


Erster Blick auf das Vergleichsdiagramm

Das folgende Diagramm vergleicht schlechte Beispiele Seite an Seite mit guten Beispielen, wobei jedes einzelne aus einer echten Fehlerkategorie stammt.

防御性编程坏好示例对照图

Die offiziellen Docs nennen diese Muster die Regeln "bitte lies dies, bevor du Lebenszyklus-, Nebenläufigkeits-, Kindprozess- oder Bereinigungscode schreibst".


Orthogonale Ergebnisse unabhängig berichtet

Ein Ergebnis kann gleichzeitig mehrere Eigenschaften haben: Ein Prozess kann eine Zeitüberschreitung gehabt haben, aber trotzdem mit Code 0 beendet werden, weil er das Beendigungssignal abgefangen hat.

Jede unabhängige Tatsache (timedOut、signal、exitCode) sollte jeweils separat gemeldet werden.

Verschachtele niemals die Meldung eines Flags im Zweig eines anderen Flags; andernfalls könnten Aufrufer einen vorzeitig beendeten Lauf für normalen Erfolg halten.

Beispiel

// Dateipfad: packages/my-shell/src/run.ts
// Führe einen Kindprozess aus und melde orthogonal drei unabhängige Fakten: timedOut, signal, exitCode.
import { spawn, type ChildProcess } from 'node:child_process'

export interface RunResult {
  timedOut: boolean      // Unabhängiger Fakt 1: ob eine Zeitüberschreitung auftrat
  signal: NodeJS.Signals | null // Unabhängiger Fakt 2: ob es durch ein Signal beendet wurde
  exitCode: number | null       // Unabhängiger Fakt 3: Exit-Code
  stdout: string
  stderr: string
}

export function run(argv: string[], timeoutMs: number): Promise<RunResult> {
  return new Promise((resolve, reject) => {
    const child: ChildProcess = spawn(argv[0], argv.slice(1), {
      stdio: ['ignore', 'pipe', 'pipe'],
    })
    let stdout = ''
    let stderr = ''
    child.stdout.on('data', (d: Buffer) => (stdout += d))
    child.stderr.on('data', (d: Buffer) => (stderr += d))

    // Unabhängige Tatsache 1, separat gepflegt: timeout ist ein Flag, unabhängig vom Exit-Code.
    let timedOut = false
    const timer = setTimeout(() => {
      timedOut = true
      child.kill('SIGTERM') // Timeout löst Beendigung aus
    }, timeoutMs)

    child.on('close', (code, signal) => {
      clearTimeout(timer)
      // Die drei Felder werden unabhängig zurückgegeben: Der Prozess kann timedOut=true und exitCode=0 haben,
      // Weil es SIGTERM nach dem Timeout abfängt und mit 0 beendet.
      resolve({ timedOut, signal, exitCode: code, stdout, stderr })
    })
    child.on('error', reject)
  })
}

Mit diesem Stil können Aufrufer Prüfungen kombinieren:timedOutist true, aberexitCodeWenn es 0 ist, bedeutet das "zwangsweise beendet, aber der Prozess hat es mit einer 0 maskiert."

Wenn die Berichterstattung verschachtelt ist, kann diese Kombination niemals ausgedrückt werden.


dispose muss einen vollständig gestoppten Zustand erreichen.

Wenn der Bereinigungsablauf nur ein Terminate- oder Abbruchsignal sendet und zurückkehrt, ohne darauf zu warten, dass die Arbeit tatsächlich stoppt, bleiben verwaiste Prozesse zurück.

Die Bereinigungslogik sollte einen asynchronen Ablauf verwenden und auf das Beenden des Kindprozesses warten (Terminate-Signal senden, dann wartendone)。

Sie sollten auch die Listener-Registrierungen und Benachrichtigungsregistrierungen schließen, bevor Sie den Prozess beenden, damit späte Abschlussereignisse still bleiben.

Beispiel

// Dateipfad: packages/my-shell/src/dispose.ts
// dispose muss vollständig stoppen: nach dem Senden des Signals auf das Beenden des Kindprozesses warten, anstatt nach dem bloßen Senden des Signals zurückzukehren.
import { once } from 'node:events'
import type { ChildProcess } from 'node:child_process'

export async function disposeQuiescent(child: ChildProcess): Promise<void> {
  // 1. Zuerst die Listener entfernen, damit späte Abschlussereignisse still bleiben.
  child.removeAllListeners()

  // 2. Stopp anfordern: Beendigungssignal senden.
  child.kill('SIGTERM')

  // 3. Warten, bis der Child-Prozess tatsächlich beendet ist; wenn er nach dem Timeout noch nicht beendet wurde, auf SIGKILL eskalieren.
  // Promise.race garantiert, dass dispose nicht für immer von einem Child-Prozess blockiert wird, der sich weigert zu beenden.
  const forceKill = new Promise<void>((resolve) => {
    setTimeout(() => {
      child.kill('SIGKILL')
      resolve()
    }, 5_000)
  })

  await Promise.race([once(child, 'exit').then(() => undefined), forceKill])
}

Auf diese Weise ist der Child-Prozess, wenn dispose zurückkehrt, entweder beendet oder mit SIGKILL zwangsweise beendet worden.


Bereinigung von Anmeldeinformationen: Umgebungsvariablen niemals in nicht vertrauenswürdiger Ausgabe preisgeben.

Gestartete Befehle sollten eine bereinigte Umgebung verwenden, wobei Namen entfernt werden, die übereinstimmen mit*KEY*、*SECRET*、*TOKEN*oder*PASSWORD*dem Element.

Andernfalls können Harness-Anmeldedaten über die Befehlsausgabe durchsickern,envoder Spill-Datei-Leaks.

Temporäre Dateien und Spill-Dateien sollten in einem privaten Verzeichnis mit 0700-Berechtigungen abgelegt werden, zufällige Dateinamen verwenden und in einer exklusiven, nur für den Eigentümer zugänglichen Weise geöffnet werden ('wx'、0o600)。

Beispiel

// Dateipfad: packages/my-shell/src/env.ts
// Umgebungsvariablen bereinigen, bevor der Befehl gestartet wird, um zu verhindern, dass Harness-Anmeldedaten über die Befehlsausgabe oder env durchsickern.
// Sensible Elemente, die mit *KEY* *SECRET* *TOKEN* *PASSWORD* übereinstimmen, werden alle entfernt.
const SENSITIVE_NAME = /.*(?:KEY|SECRET|TOKEN|PASSWORD).*/i

export function scrubEnv(env: NodeJS.ProcessEnv): Record<string, string> {
  const clean: Record<string, string> = {}
  for (const [key, value] of Object.entries(env)) {
    if (SENSITIVE_NAME.test(key)) continue // Sensible Schlüssel direkt überspringen
    if (value !== undefined) clean[key] = value
  }
  return clean
}

// Verwendung:spawn(argv[0], argv.slice(1), { env: scrubEnv(process.env), ... })

In der Beispielumgebung,DEEPSEEK_API_KEYSolche Variablen müssen diesen Löschpfad durchlaufen.

Lassen Sie Befehlsausgaben,envoder Spill-Dateien, die sie enthalten, zu, was gleichbedeutend damit ist, die Schlüssel an alles weiterzugeben, was die Ausgabe lesen kann.


Symlinks mit unlink löschen.

Ein Pfad, der ein Symlink oder Windows-Junction sein könnte, sollte zuerst verwendenlstatSync().isSymbolicLink()Beurteilen, dann verwendenunlinkSyncLöschen.

unlink entfernt nur den Link selbst und verweigert echte Verzeichnisse, sodass es niemals einem Link in sein Ziel folgt.

Aufruf von junction unter WindowsrmSync(link)Löst eine Ausnahme ausERR_FS_EISDIR; rekursives Löschen kann die Junction in ihr Ziel durchlaufen.

Nur echte Verzeichnisse verwenden taperecursivevonrmSync。

Beispiel

// Dateipfad: packages/my-fs/src/remove.ts
// Link-Pfade mit unlink löschen, niemals Links rekursiv in ihre Ziele verfolgen.
import { lstatSync, rmSync, unlinkSync } from 'node:fs'

export function removePath(p: string): void {
  // Zuerst prüfen, ob es ein symbolischer Link (oder eine Windows-Junction) ist.
  if (lstatSync(p).isSymbolicLink()) {
    // unlink löscht nur den Link selbst und verweigert echte Verzeichnisse; er folgt keinen Links
    unlinkSync(p)
    return
  }
  // Erst nachdem das tatsächliche Verzeichnis bestätigt wurde, rmSync mit recursive verwenden.
  rmSync(p, { recursive: true })
}

Callback-Ausnahmen im Dispatcher isolieren

Wenn ein vom Benutzer bereitgestellter Listener eine Ausnahme auslöst, darf dies nicht dazu führen, dass das Promise, zu dem er gehört, abgelehnt wird, noch die dahinter in der Warteschlange stehenden Listener aushungern.

Umschließe die Dispatch-Schleife mit try/catch und protokolliere Fehler.

Ein sich fehlverhaltender Subscriber darf niemals den Kern-Lebenszyklus unterbrechen.

Beispiel

// Dateipfad: packages/my-events/src/dispatch.ts
// Dispatcher isoliert Callback-Ausnahmen: Ein fehlerhafter Subscriber kann den Kern-Lebenszyklus nicht unterbrechen.
export function dispatch(listeners: ReadonlyArray<() => void>): void {
  for (const listener of listeners) {
    try {
      listener()
    } catch (err) {
      // Protokollieren und mit dem Dispatch fortfahren, niemals ablehnen und niemals nachfolgende Listener aushungern.
      console.error('[dispatch] Ein Listener ist fehlgeschlagen:', err)
    }
  }
}

Dieses Muster ist überall in dsh zu finden:session/eventObserver-Fehler werden protokolliert und isoliert, anstatt bereits festgeschriebene Appends fehlschlagen zu lassen.

Es garantiert, dass "ein Plugin, das seinen Listener zum Absturz bringt", nicht den gesamten Agent-Loop zum Stillstand bringt.


Überblick über weitere Modi

Die offiziellen Docs verzeichnen außerdem zwei weitere, die es sich zu merken lohnt:

ModeRegeln
Beide Seiten des öffentlichen Vertrags müssen sich daran haltenWenn mehrere Darstellungen desselben Ergebnisses empfangen werden, normalisiere sie, bevor sie durch die öffentliche API zurückgegeben werden; Konsumenten sollten nicht raten müssen, ob Ausnahmen vom Provider, von der Wrapper-Schicht oder von ihrer eigenen Assembly-Logik stammen
Asynchroner Zustand ist nicht synchroner ZustandFügen Sieagent/statusoderwhenIdle()Als Instanzfollowup()das Ergebnis von; der Aufrufer, der einen Lauf wirklich besitzt, muss das Intervall explizit definieren

Die testbezogenen Gegenstücke dieser Regeln (echte Einstiegspfade, Überprüfung tatsächlicher Ergebnisse, Ressourceneigentümerschaft) sind in testing.md ausführlich beschrieben, was im nächsten Beitrag behandelt wird.


Zusammenfassung Selbsttest

Defensive Programmierung stopft im Voraus die Fallstricke, die an Grenzen leicht zum Scheitern führen: orthogonale Ergebnisberichterstattung, vollständig quiesziertes Dispose, Löschung von Anmeldedaten, sichere Link-Löschung und Isolierung von Callback-Ausnahmen.

Selbsttestfragen:

ProblemReferenz
Bei einem Prozess ist eine Zeitüberschreitung aufgetreten, aber der Exit-Code ist 0 – was bedeutet das?Er hat möglicherweise das Beendigungssignal abgefangen und sich selbst mit 0 beendet; timedOut und exitCode sind zwei unabhängige Fakten.
Welches Risiko besteht, wenn man beim Bereinigen eines Kindprozesses nur SIGTERM sendet und dann zurückkehrt?Es hinterlässt verwaiste Prozesse; du solltest auf das Beenden warten und dann bei Zeitüberschreitung auf SIGKILL eskalieren.
Was solltest du verwenden, um einen Pfad zu löschen, der ein Symlink sein könnte?Zuerst mit lstatSync prüfen, dann unlinkSync verwenden; rmSync recursive nur für echte Verzeichnisse verwenden.
Weitere Erweiterungen