Files
deepseek-harness/examples/desktop/test/transport-dead-write-guard.test.js
T
ZiyaZhang e8f5c0b51b feat(desktop): DSH Electron desktop shell — harness internals visualized
Minimal Electron shell over the DSH JSON-RPC runtime — a first-look at
what a ChatGPT.app-style host on top of the DeepSeek Harness looks
like, with the harness's normally-invisible internals (trace timeline,
context surface, subagent tree, compaction, plugin registry, rubrics)
brought forward as first-class UI surfaces so plugin authors and
researchers can see what the agent is actually doing.

Runs against three keyless-to-live profiles (stdio-echo works on
master out of the box; daemon-echo / daemon-vibe-echo activate once
the daemon-demo lands; stdio-deepseek and daemon-vibe hit the real
DeepSeek API when you supply a key). HARNESS_DEV auto-resolves to the
in-repo runtime when this shell ships under examples/desktop/, so a
fresh clone launches without config; env DSH_DEV_ROOT overrides for
custom layouts, and a sibling deepseek-harness-dev/ checkout is the
original dev workflow.

Cold-clone gate (P0 fixes for first-time-clone usability):
- HARNESS_DEV: 3-candidate resolver (env → walk-up in-repo marker →
  sibling), unit-tested via mock fs so ordering is locked without
  needing either real layout on disk.
- config yml leaves rewritten at assemble time so the sibling-clone
  paths (../../deepseek-harness-dev/examples/echo-agent/…) become
  the in-repo paths (../../echo-agent/…) in the released tree —
  source yml stays usable for local dev, released tree ships a
  working shape.
- pnpm-workspace.yaml allowBuilds.electron = true (was placeholder).
- missing-key card in stdio-deepseek offers a one-click switch to
  stdio-echo (the keyless profile that works on master) rather than
  daemon-echo (blocked on the not-yet-shipped daemon-demo).
- assemble-oss-release.sh rewrites the source-side breadcrumb name
  'dsh-desktop-demo' → 'dsh-desktop' for the released package.json.

FOUC guard on the onboarding gate (41fc5df carried) keeps the
first-launch splash from flashing before the runtime probe finishes.

Test suite (1634 tests in source, 3990 in the runtime repo) covers
resolver ordering, renderer classifiers, trace timeline shape,
compaction diff rendering, rubric parity, and the missing-key
onboarding paths.
2026-07-18 12:59:34 -07:00

109 lines
4.6 KiB
JavaScript

// StdioTransport.write must not throw after the child has exited
//
// The default-profile-real probe surfaced a race: when the deepseek runtime
// dies during plugin load (config drift, missing api key, spawn ENOENT),
// the supervisor still has a queued initialize frame in flight. transport.js
// used to `throw new Error('runtime not writable')` synchronously if
// child.stdin.writable was false, and the caller (runtime.js _spawnOnce)
// wraps that in try/catch → emits `protocolError` → the renderer shows a
// generic "Runtime warning" banner with the bland message. Meanwhile the
// child's real stderr (e.g. `llm-deepseek: an API key is required`) is
// still being drained into stderrAccum; the exit → crash handler would
// scan and classify it correctly — but only if `protocolError` didn't
// beat it to the banner.
//
// Fix: drop the frame silently (emit `dropped` so tests + verbose logs can
// observe it), let the exit path fire, let the crash handler read the
// stderr tail, and let the classifier surface the real cause.
//
// This test drives the guard directly using a mock child (spawn is stubbed
// via a custom module hook). We construct a StdioTransport, call `start()`,
// simulate the child exiting, then call `write()`. The call must return
// without throwing and must emit `dropped` with the frame.
'use strict'
const test = require('node:test')
const assert = require('node:assert/strict')
const path = require('node:path')
const { EventEmitter } = require('node:events')
// Load StdioTransport with an intercepted `node:child_process` so `spawn`
// returns a controllable fake child. Same pattern as the daemon supervisor
// tests — Module._load override keyed on the exact module id.
const Module = require('node:module')
const origLoad = Module._load
const spawnCalls = []
let fakeChild = null
function makeFakeChild() {
const ee = new EventEmitter()
ee.stdin = new EventEmitter()
ee.stdin.writable = true
ee.stdin.write = (frame) => { ee.stdin._lastFrame = frame; return true }
ee.stdin.end = () => { ee.stdin.writable = false }
ee.stdout = new EventEmitter()
ee.stderr = new EventEmitter()
ee.killed = false
ee.kill = () => { ee.killed = true }
return ee
}
Module._load = function patched(request, parent, ...rest) {
if (request === 'node:child_process') {
return {
spawn(cmd, args, opts) {
spawnCalls.push({ cmd, args, opts })
fakeChild = makeFakeChild()
return fakeChild
},
}
}
return origLoad.call(this, request, parent, ...rest)
}
const { StdioTransport } = require(path.join(__dirname, '..', 'src', 'main', 'transport.js'))
test.after(() => { Module._load = origLoad })
test('write() drops silently and emits `dropped` when child has exited', () => {
const t = new StdioTransport({ cmd: 'node', args: ['-e', ';'], cwd: '/tmp', env: {} })
t.start()
assert.ok(fakeChild, 'spawn stub must have fired')
// Simulate the child exiting mid-init (schema drift, missing key).
fakeChild.stdin.writable = false
const exits = []
t.on('exit', (info) => exits.push(info))
fakeChild.emit('exit', 0, null)
// A queued frame arrives after the exit. Must not throw.
const dropped = []
t.on('dropped', (frame) => dropped.push(frame))
assert.doesNotThrow(() => t.write('{"jsonrpc":"2.0","method":"initialize","id":1}\n'))
assert.equal(dropped.length, 1, 'dropped frame must fire an event so verbose logs can see it')
assert.match(dropped[0], /initialize/, 'the actual frame must be forwarded to the dropped event')
// The exit handler must have fired so the crash → classify path can run.
assert.equal(exits.length, 1, 'exit must have propagated with the stderr tail')
})
test('write() drops silently before start() has spawned a child', () => {
const t = new StdioTransport({ cmd: 'node', args: [], cwd: '/tmp', env: {} })
// No .start() call — child is still null. A stray write must not crash the
// supervisor; a caller that races start would otherwise land in the
// generic protocolError banner path.
const dropped = []
t.on('dropped', (frame) => dropped.push(frame))
assert.doesNotThrow(() => t.write('early-frame'))
assert.equal(dropped.length, 1, 'pre-start write must emit dropped')
})
test('write() forwards to child.stdin when the child is alive', () => {
const t = new StdioTransport({ cmd: 'node', args: [], cwd: '/tmp', env: {} })
t.start()
const dropped = []
t.on('dropped', (frame) => dropped.push(frame))
t.write('live-frame')
assert.equal(dropped.length, 0, 'live writes must not fall into the dropped path')
assert.equal(fakeChild.stdin._lastFrame, 'live-frame', 'the frame must reach child.stdin.write')
})