@gemus/mcp-proxy 0.1.8 → 0.1.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +69 -15
- package/package.json +5 -3
- package/src/failFast.mjs +23 -10
- package/src/proxy.mjs +29 -404
- package/src/proxyMessages.mjs +38 -0
- package/src/relay.mjs +440 -0
- package/src/setup/cli.mjs +130 -0
- package/src/setup/codex.mjs +110 -0
- package/src/setup/config.mjs +530 -0
- package/src/setup/configFile.mjs +155 -0
- package/src/setup/environment.mjs +79 -0
- package/src/setup/secret.mjs +91 -0
- package/src/startup.mjs +16 -0
- package/src/upstreamHttp.mjs +69 -0
- package/src/__tests__/backfill.test.ts +0 -394
- package/src/__tests__/failFast.test.ts +0 -254
- package/src/__tests__/fixtures/proxy-runtime-loader.mjs +0 -136
- package/src/__tests__/mcpHeaders.test.ts +0 -32
- package/src/__tests__/route.integration.test.ts +0 -68
- package/src/__tests__/startup.test.ts +0 -558
|
@@ -1,394 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* @gemus/mcp-proxy backfill orchestration (#1751, Mode B). Mocks only the core's NETWORK + rollout-IO
|
|
5
|
-
* (deliverImageToNode / addImageToCanvas / readRollout / findRolloutFile); keeps extractImageOutputs +
|
|
6
|
-
* ExecuteClaimTracker + unwrapMcpContent REAL so the tap→tracker→claim/salvage wiring is exercised
|
|
7
|
-
* end-to-end. Shares the same @modelcontextprotocol/sdk instance as bridge tests via the vitest alias.
|
|
8
|
-
*/
|
|
9
|
-
const mocks = vi.hoisted(() => ({
|
|
10
|
-
deliverImageToNode: vi.fn(),
|
|
11
|
-
addImageToCanvas: vi.fn(),
|
|
12
|
-
readRollout: vi.fn(),
|
|
13
|
-
findRolloutFile: vi.fn(),
|
|
14
|
-
readGeneratedImageFiles: vi.fn(),
|
|
15
|
-
}))
|
|
16
|
-
|
|
17
|
-
vi.mock('@gemus/codex-backfill-core', async (importOriginal) => {
|
|
18
|
-
const actual = await importOriginal<typeof import('@gemus/codex-backfill-core')>()
|
|
19
|
-
return {
|
|
20
|
-
...actual, // real extractImageOutputs / ExecuteClaimTracker / EXECUTE_TOOL_NAMES / unwrapMcpContent
|
|
21
|
-
deliverImageToNode: mocks.deliverImageToNode,
|
|
22
|
-
addImageToCanvas: mocks.addImageToCanvas,
|
|
23
|
-
readRollout: mocks.readRollout,
|
|
24
|
-
findRolloutFile: mocks.findRolloutFile,
|
|
25
|
-
readGeneratedImageFiles: mocks.readGeneratedImageFiles,
|
|
26
|
-
}
|
|
27
|
-
})
|
|
28
|
-
|
|
29
|
-
import { createBackfiller, normalizeToolName, injectSteering, COMPANION_STEERING } from '../backfill.mjs'
|
|
30
|
-
import { EXECUTE_TOOL_NAMES } from '@gemus/codex-backfill-core'
|
|
31
|
-
|
|
32
|
-
/** rollout JSONL with N imagegen images (distinct call_ids, PNG-magic base64 so mime→png). */
|
|
33
|
-
function rollout(images: Array<[string, string]>): string {
|
|
34
|
-
return images
|
|
35
|
-
.map(([callId, b64]) =>
|
|
36
|
-
JSON.stringify({ payload: { type: 'image_generation_end', call_id: callId, result: b64 } }),
|
|
37
|
-
)
|
|
38
|
-
.join('\n')
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
/** MCP ContentBlock[] wrap of a structured tool result (what the wire carries; unwrapMcpContent restores it). */
|
|
42
|
-
function wrap(obj: unknown) {
|
|
43
|
-
return [{ type: 'text', text: JSON.stringify(obj) }]
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
const IMG_A: [string, string] = ['ig_a', 'iVBORw0KGgoAAAA1']
|
|
47
|
-
const IMG_B: [string, string] = ['ig_b', 'iVBORw0KGgoAAAA2']
|
|
48
|
-
|
|
49
|
-
/** Simulate one `execute` Phase-1 awaiting_client_content anchor for `nodeId` on a turn. */
|
|
50
|
-
function observeExecuteAnchor(
|
|
51
|
-
bf: ReturnType<typeof createBackfiller>,
|
|
52
|
-
jsonrpcId: number,
|
|
53
|
-
turnKey: string,
|
|
54
|
-
sessionId: string,
|
|
55
|
-
workflowId: string,
|
|
56
|
-
nodeId: string,
|
|
57
|
-
) {
|
|
58
|
-
bf.observeCall({ jsonrpcId, turnKey, sessionId, name: 'execute', args: { workflowId, nodeId, dryRun: true } })
|
|
59
|
-
bf.observeResult({
|
|
60
|
-
jsonrpcId,
|
|
61
|
-
turnKey,
|
|
62
|
-
name: 'execute',
|
|
63
|
-
resultContent: wrap({ status: 'awaiting_client_content', nodeId }),
|
|
64
|
-
})
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
function makeBackfiller() {
|
|
68
|
-
return createBackfiller({ server: 'http://localhost:3000', apiKey: 'mak_test', codexHome: '/fake/.codex' })
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
beforeEach(() => {
|
|
72
|
-
mocks.deliverImageToNode.mockReset().mockResolvedValue({ ok: true, status: 'processing' })
|
|
73
|
-
mocks.addImageToCanvas.mockReset().mockResolvedValue({ ok: true, entityId: 'agent_x' })
|
|
74
|
-
mocks.readRollout.mockReset().mockReturnValue('')
|
|
75
|
-
mocks.findRolloutFile.mockReset().mockReturnValue('/fake/rollout.jsonl')
|
|
76
|
-
// Default: generated_images unavailable → backfill falls back to the rollout source (existing tests).
|
|
77
|
-
mocks.readGeneratedImageFiles.mockReset().mockReturnValue({ available: false, images: [], failedCallIds: [] })
|
|
78
|
-
})
|
|
79
|
-
|
|
80
|
-
describe('normalizeToolName (tap → tracker)', () => {
|
|
81
|
-
it('maps wire execute names to the tracker constants', () => {
|
|
82
|
-
expect(normalizeToolName('execute')).toBe('mcp__gemus__execute')
|
|
83
|
-
expect(normalizeToolName('batch_execute')).toBe('mcp__gemus__batch_execute')
|
|
84
|
-
// the whole point: the normalized name must be one the tracker actually matches.
|
|
85
|
-
expect(EXECUTE_TOOL_NAMES.has(normalizeToolName('execute'))).toBe(true)
|
|
86
|
-
expect(EXECUTE_TOOL_NAMES.has(normalizeToolName('batch_execute'))).toBe(true)
|
|
87
|
-
})
|
|
88
|
-
it('passes non-execute tool names through unchanged', () => {
|
|
89
|
-
expect(normalizeToolName('canvas_read')).toBe('canvas_read')
|
|
90
|
-
expect(normalizeToolName('workflow_list')).toBe('workflow_list')
|
|
91
|
-
})
|
|
92
|
-
})
|
|
93
|
-
|
|
94
|
-
describe('injectSteering (Mode-B initialize instructions)', () => {
|
|
95
|
-
it('appends a search-neutral skill bootstrap to initialize instructions', () => {
|
|
96
|
-
const msg = { jsonrpc: '2.0', id: 1, result: { protocolVersion: '2024-11-05', serverInfo: { name: 'gemus' } } }
|
|
97
|
-
expect(injectSteering(msg)).toBe(true)
|
|
98
|
-
expect(msg.result.instructions).toContain('gemus-getting-started')
|
|
99
|
-
expect(msg.result.instructions).toContain('gemus-codex-imagen')
|
|
100
|
-
expect(msg.result.instructions).toBe(COMPANION_STEERING)
|
|
101
|
-
})
|
|
102
|
-
|
|
103
|
-
it('does not pollute deferred search documents with registered tool names', () => {
|
|
104
|
-
const toolNames = [
|
|
105
|
-
'canvas_read', 'view_image', 'node_list', 'workflow_list', 'canvas_edit', 'ppt_edit',
|
|
106
|
-
'open_canvas', 'blueprint', 'execute', 'batch_execute', 'recall_history',
|
|
107
|
-
]
|
|
108
|
-
for (const toolName of toolNames) {
|
|
109
|
-
const exactName = new RegExp(`(?<![A-Za-z0-9_])${toolName}(?![A-Za-z0-9_])`, 'i')
|
|
110
|
-
expect(COMPANION_STEERING, `companion steering references ${toolName}`).not.toMatch(exactName)
|
|
111
|
-
}
|
|
112
|
-
})
|
|
113
|
-
|
|
114
|
-
it('preserves any existing server instructions (appends, not replaces)', () => {
|
|
115
|
-
const msg = { id: 1, result: { protocolVersion: '1', instructions: 'server says hi' } }
|
|
116
|
-
injectSteering(msg)
|
|
117
|
-
expect(msg.result.instructions).toContain('server says hi')
|
|
118
|
-
expect(msg.result.instructions).toContain('Gemus companion')
|
|
119
|
-
})
|
|
120
|
-
|
|
121
|
-
it('is a no-op for non-initialize messages (tool results, notifications)', () => {
|
|
122
|
-
const toolResult = { id: 5, result: { content: [{ type: 'text', text: '{}' }] } }
|
|
123
|
-
expect(injectSteering(toolResult)).toBe(false)
|
|
124
|
-
expect(toolResult.result.instructions).toBeUndefined()
|
|
125
|
-
expect(injectSteering({ method: 'notifications/message' })).toBe(false)
|
|
126
|
-
})
|
|
127
|
-
})
|
|
128
|
-
|
|
129
|
-
describe('backfillTurn — claim path (Stop hook)', () => {
|
|
130
|
-
it('delivers each scavenged image to its planned node (key-only: real workflowId, no sessionId header)', async () => {
|
|
131
|
-
const bf = makeBackfiller()
|
|
132
|
-
observeExecuteAnchor(bf, 1, 't1', 's1', 'wf_real', 'nodeA')
|
|
133
|
-
observeExecuteAnchor(bf, 2, 't1', 's1', 'wf_real', 'nodeB')
|
|
134
|
-
mocks.readRollout.mockReturnValue(rollout([IMG_A, IMG_B]))
|
|
135
|
-
|
|
136
|
-
const r = await bf.backfillTurn({ turnKey: 't1', sessionId: 's1', rolloutPath: '/precise.jsonl', salvageOnly: false })
|
|
137
|
-
|
|
138
|
-
expect(r).toEqual({ delivered: 2, orphaned: 0, dropped: 0 })
|
|
139
|
-
// Stop hook path reads the PRECISE rollout (transcript_path), not findRolloutFile.
|
|
140
|
-
expect(mocks.readRollout).toHaveBeenCalledWith('/precise.jsonl')
|
|
141
|
-
expect(mocks.findRolloutFile).not.toHaveBeenCalled()
|
|
142
|
-
// N:N ordered claim: nodeA→first image, nodeB→second.
|
|
143
|
-
expect(mocks.deliverImageToNode).toHaveBeenCalledTimes(2)
|
|
144
|
-
const calls = mocks.deliverImageToNode.mock.calls.map((c) => c[0])
|
|
145
|
-
expect(calls[0]).toMatchObject({ workflowId: 'wf_real', nodeId: 'nodeA', image: { callId: 'ig_a' } })
|
|
146
|
-
expect(calls[1]).toMatchObject({ workflowId: 'wf_real', nodeId: 'nodeB', image: { callId: 'ig_b' } })
|
|
147
|
-
// key-only invariant: no X-Gemus-Session-Id (no sessionId passed to the delivery fn).
|
|
148
|
-
expect(calls[0].sessionId).toBeUndefined()
|
|
149
|
-
expect(mocks.addImageToCanvas).not.toHaveBeenCalled()
|
|
150
|
-
})
|
|
151
|
-
|
|
152
|
-
it('falls back to orphan when deliver fails (e.g. already_completed) — image not lost', async () => {
|
|
153
|
-
const bf = makeBackfiller()
|
|
154
|
-
observeExecuteAnchor(bf, 1, 't1', 's1', 'wf_real', 'nodeA')
|
|
155
|
-
mocks.readRollout.mockReturnValue(rollout([IMG_A]))
|
|
156
|
-
mocks.deliverImageToNode.mockResolvedValue({ ok: false, code: 'ALREADY_COMPLETED' })
|
|
157
|
-
|
|
158
|
-
const r = await bf.backfillTurn({ turnKey: 't1', sessionId: 's1', rolloutPath: '/x', salvageOnly: false })
|
|
159
|
-
|
|
160
|
-
expect(mocks.deliverImageToNode).toHaveBeenCalledTimes(1)
|
|
161
|
-
expect(mocks.addImageToCanvas).toHaveBeenCalledTimes(1) // orphan fallback
|
|
162
|
-
expect(r).toEqual({ delivered: 0, orphaned: 1, dropped: 0 })
|
|
163
|
-
})
|
|
164
|
-
})
|
|
165
|
-
|
|
166
|
-
describe('backfillTurn — salvageOnly fallback (untrusted hook / idle timer)', () => {
|
|
167
|
-
it('orphans every fresh image and NEVER calls claim/deliver, even with pending anchors', async () => {
|
|
168
|
-
const bf = makeBackfiller()
|
|
169
|
-
// A legit multi-image turn with pending anchors — salvage must NOT mis-claim them.
|
|
170
|
-
observeExecuteAnchor(bf, 1, 't1', 's1', 'wf_real', 'nodeA')
|
|
171
|
-
observeExecuteAnchor(bf, 2, 't1', 's1', 'wf_real', 'nodeB')
|
|
172
|
-
mocks.readRollout.mockReturnValue(rollout([IMG_A, IMG_B]))
|
|
173
|
-
|
|
174
|
-
const r = await bf.backfillTurn({ turnKey: 't1', rolloutPath: null, salvageOnly: true })
|
|
175
|
-
|
|
176
|
-
// idle fallback has no transcript_path → locates rollout via findRolloutFile(session_id).
|
|
177
|
-
expect(mocks.findRolloutFile).toHaveBeenCalledWith('/fake/.codex', 's1')
|
|
178
|
-
expect(mocks.deliverImageToNode).not.toHaveBeenCalled() // never claim in salvage
|
|
179
|
-
expect(mocks.addImageToCanvas).toHaveBeenCalledTimes(2)
|
|
180
|
-
expect(r).toEqual({ delivered: 0, orphaned: 2, dropped: 0 })
|
|
181
|
-
})
|
|
182
|
-
})
|
|
183
|
-
|
|
184
|
-
describe('backfillTurn — idempotency + guards', () => {
|
|
185
|
-
it('is idempotent across the two triggers via the seen-set (2nd pass no-ops)', async () => {
|
|
186
|
-
const bf = makeBackfiller()
|
|
187
|
-
observeExecuteAnchor(bf, 1, 't1', 's1', 'wf_real', 'nodeA')
|
|
188
|
-
mocks.readRollout.mockReturnValue(rollout([IMG_A]))
|
|
189
|
-
|
|
190
|
-
await bf.backfillTurn({ turnKey: 't1', sessionId: 's1', rolloutPath: '/x', salvageOnly: false })
|
|
191
|
-
mocks.deliverImageToNode.mockClear()
|
|
192
|
-
mocks.addImageToCanvas.mockClear()
|
|
193
|
-
// A late idle-timer fire for the same turn: all call_ids already seen → nothing to do.
|
|
194
|
-
const r = await bf.backfillTurn({ turnKey: 't1', sessionId: 's1', rolloutPath: null, salvageOnly: true })
|
|
195
|
-
|
|
196
|
-
expect(r).toEqual({ delivered: 0, orphaned: 0, dropped: 0 })
|
|
197
|
-
expect(mocks.deliverImageToNode).not.toHaveBeenCalled()
|
|
198
|
-
expect(mocks.addImageToCanvas).not.toHaveBeenCalled()
|
|
199
|
-
})
|
|
200
|
-
|
|
201
|
-
it('a FAILED salvage does not burn the image — a later Stop hook still claims it (#1751 seen-on-success)', async () => {
|
|
202
|
-
const bf = makeBackfiller()
|
|
203
|
-
observeExecuteAnchor(bf, 1, 't1', 's1', 'wf_real', 'nodeA')
|
|
204
|
-
mocks.readRollout.mockReturnValue(rollout([IMG_A]))
|
|
205
|
-
// 1) an idle salvage fires but the orphan fails (e.g. session cap) → image must NOT be marked seen.
|
|
206
|
-
mocks.addImageToCanvas.mockResolvedValueOnce({ ok: false, error: 'session cap reached' })
|
|
207
|
-
const r1 = await bf.backfillTurn({ turnKey: 't1', rolloutPath: null, salvageOnly: true })
|
|
208
|
-
expect(r1.orphaned).toBe(0)
|
|
209
|
-
// 2) the real Stop hook fires → the image is still fresh → claim to the planned node succeeds.
|
|
210
|
-
const r2 = await bf.backfillTurn({ turnKey: 't1', sessionId: 's1', rolloutPath: '/x', salvageOnly: false })
|
|
211
|
-
expect(mocks.deliverImageToNode).toHaveBeenCalledTimes(1)
|
|
212
|
-
expect(mocks.deliverImageToNode.mock.calls[0][0]).toMatchObject({ nodeId: 'nodeA' })
|
|
213
|
-
expect(r2.delivered).toBe(1)
|
|
214
|
-
})
|
|
215
|
-
|
|
216
|
-
it('an early idle tick (image not flushed yet) keeps turn state for the later hook (#1751 no-delete-on-empty)', async () => {
|
|
217
|
-
const bf = makeBackfiller()
|
|
218
|
-
observeExecuteAnchor(bf, 1, 't1', 's1', 'wf_real', 'nodeA')
|
|
219
|
-
// 1) idle fires mid-generation, before imagegen flushed to rollout → empty, must be a pure no-op.
|
|
220
|
-
mocks.readRollout.mockReturnValueOnce('')
|
|
221
|
-
const r1 = await bf.backfillTurn({ turnKey: 't1', rolloutPath: null, salvageOnly: true })
|
|
222
|
-
expect(r1).toEqual({ delivered: 0, orphaned: 0, dropped: 0 })
|
|
223
|
-
expect(mocks.addImageToCanvas).not.toHaveBeenCalled()
|
|
224
|
-
// 2) hook fires after flush → the pending anchor + workflowId survived → claim succeeds.
|
|
225
|
-
mocks.readRollout.mockReturnValue(rollout([IMG_A]))
|
|
226
|
-
const r2 = await bf.backfillTurn({ turnKey: 't1', sessionId: 's1', rolloutPath: '/x', salvageOnly: false })
|
|
227
|
-
expect(mocks.deliverImageToNode).toHaveBeenCalledTimes(1)
|
|
228
|
-
expect(r2.delivered).toBe(1)
|
|
229
|
-
})
|
|
230
|
-
|
|
231
|
-
it('serializes concurrent passes so a racing salvage does not double-place an in-flight claim (#1751)', async () => {
|
|
232
|
-
// Real-machine bug (dev.log 1400–1544): a claim pass (Stop hook) and a salvage pass (idle timer)
|
|
233
|
-
// ran concurrently over the same session rollout. Because `seen` is only marked AFTER each slow
|
|
234
|
-
// awaited deliver, the salvage pass read the still-in-flight images as "fresh" and orphaned them —
|
|
235
|
-
// the same image landed BOTH on its planned gen node AND as a duplicate image-upload node.
|
|
236
|
-
const bf = makeBackfiller()
|
|
237
|
-
observeExecuteAnchor(bf, 1, 't1', 's1', 'wf_real', 'nodeA')
|
|
238
|
-
observeExecuteAnchor(bf, 2, 't1', 's1', 'wf_real', 'nodeB')
|
|
239
|
-
mocks.readRollout.mockReturnValue(rollout([IMG_A, IMG_B]))
|
|
240
|
-
|
|
241
|
-
// deliver resolves only when released — models the 6-20s awaited media job seen in the logs.
|
|
242
|
-
let releaseDeliver!: () => void
|
|
243
|
-
const gate = new Promise<void>((res) => {
|
|
244
|
-
releaseDeliver = res
|
|
245
|
-
})
|
|
246
|
-
mocks.deliverImageToNode.mockImplementation(async () => {
|
|
247
|
-
await gate
|
|
248
|
-
return { ok: true, status: 'processing' }
|
|
249
|
-
})
|
|
250
|
-
|
|
251
|
-
// Fire the claim pass (parks on the deliver gate), then a racing salvage pass — do NOT await between.
|
|
252
|
-
const claimPass = bf.backfillTurn({ turnKey: 't1', sessionId: 's1', rolloutPath: '/x', salvageOnly: false })
|
|
253
|
-
const salvagePass = bf.backfillTurn({ turnKey: 't1', rolloutPath: null, salvageOnly: true })
|
|
254
|
-
releaseDeliver()
|
|
255
|
-
const [claimResult, salvageResult] = await Promise.all([claimPass, salvagePass])
|
|
256
|
-
|
|
257
|
-
// Both images delivered to their planned nodes; the racing salvage placed NO duplicate orphans.
|
|
258
|
-
expect(claimResult).toEqual({ delivered: 2, orphaned: 0, dropped: 0 })
|
|
259
|
-
expect(salvageResult).toEqual({ delivered: 0, orphaned: 0, dropped: 0 })
|
|
260
|
-
expect(mocks.addImageToCanvas).not.toHaveBeenCalled()
|
|
261
|
-
})
|
|
262
|
-
|
|
263
|
-
it('does nothing (no placement) when no workflowId was ever captured', async () => {
|
|
264
|
-
const bf = makeBackfiller()
|
|
265
|
-
// No tool call carried a workflowId this turn.
|
|
266
|
-
mocks.readRollout.mockReturnValue(rollout([IMG_A]))
|
|
267
|
-
const r = await bf.backfillTurn({ turnKey: 't1', sessionId: 's1', rolloutPath: '/x', salvageOnly: false })
|
|
268
|
-
expect(r).toEqual({ delivered: 0, orphaned: 0, dropped: 0 })
|
|
269
|
-
expect(mocks.deliverImageToNode).not.toHaveBeenCalled()
|
|
270
|
-
expect(mocks.addImageToCanvas).not.toHaveBeenCalled()
|
|
271
|
-
})
|
|
272
|
-
})
|
|
273
|
-
|
|
274
|
-
describe('backfillTurn — footprint-0 call passthrough + timeout semantics (#1756 Layer 1a)', () => {
|
|
275
|
-
it('threads the injected `call` into deliver AND orphan (footprint-0)', async () => {
|
|
276
|
-
const call = vi.fn()
|
|
277
|
-
const bf = createBackfiller({
|
|
278
|
-
server: 'http://localhost:3000',
|
|
279
|
-
apiKey: 'mak_test',
|
|
280
|
-
codexHome: '/fake/.codex',
|
|
281
|
-
call,
|
|
282
|
-
})
|
|
283
|
-
observeExecuteAnchor(bf, 1, 't1', 's1', 'wf_real', 'nodeA')
|
|
284
|
-
mocks.readRollout.mockReturnValue(rollout([IMG_A]))
|
|
285
|
-
mocks.deliverImageToNode.mockResolvedValue({ ok: false, code: 'ALREADY_COMPLETED' }) // force orphan fallback
|
|
286
|
-
|
|
287
|
-
await bf.backfillTurn({ turnKey: 't1', sessionId: 's1', rolloutPath: '/x', salvageOnly: false })
|
|
288
|
-
|
|
289
|
-
expect(mocks.deliverImageToNode.mock.calls[0][0]).toMatchObject({ call })
|
|
290
|
-
expect(mocks.addImageToCanvas.mock.calls[0][0]).toMatchObject({ call })
|
|
291
|
-
})
|
|
292
|
-
|
|
293
|
-
it('a timed-out deliver leaves the image FRESH — no orphan, not marked seen (no duplicate)', async () => {
|
|
294
|
-
const bf = makeBackfiller()
|
|
295
|
-
observeExecuteAnchor(bf, 1, 't1', 's1', 'wf_real', 'nodeA')
|
|
296
|
-
mocks.readRollout.mockReturnValue(rollout([IMG_A]))
|
|
297
|
-
// deliver times out → result unknown; the platform job may still land, so we must NOT orphan.
|
|
298
|
-
mocks.deliverImageToNode.mockResolvedValue({ ok: false, timedOut: true, error: 'inject timeout' })
|
|
299
|
-
|
|
300
|
-
const r1 = await bf.backfillTurn({ turnKey: 't1', sessionId: 's1', rolloutPath: '/x', salvageOnly: false })
|
|
301
|
-
expect(r1).toEqual({ delivered: 0, orphaned: 0, dropped: 0 })
|
|
302
|
-
expect(mocks.addImageToCanvas).not.toHaveBeenCalled() // NO orphan on timeout
|
|
303
|
-
|
|
304
|
-
// Image stayed fresh (not seen): a later Stop-hook pass can still deliver it once the job lands.
|
|
305
|
-
mocks.deliverImageToNode.mockResolvedValue({ ok: true, status: 'processing' })
|
|
306
|
-
const r2 = await bf.backfillTurn({ turnKey: 't1', sessionId: 's1', rolloutPath: '/x', salvageOnly: false })
|
|
307
|
-
expect(r2.delivered).toBe(1)
|
|
308
|
-
})
|
|
309
|
-
})
|
|
310
|
-
|
|
311
|
-
describe('backfillTurn — Layer 2 file byte source (#1756)', () => {
|
|
312
|
-
it('uses generated_images when available and does NOT read the rollout', async () => {
|
|
313
|
-
const bf = makeBackfiller()
|
|
314
|
-
observeExecuteAnchor(bf, 1, 't1', 's1', 'wf_real', 'nodeA')
|
|
315
|
-
// generated_images available with one fresh image → file source wins, rollout untouched.
|
|
316
|
-
mocks.readGeneratedImageFiles.mockReturnValue({
|
|
317
|
-
available: true,
|
|
318
|
-
images: [{ callId: 'call_current', base64: 'iVBORw0KGgoAAAA1', mimeType: 'image/png' }],
|
|
319
|
-
})
|
|
320
|
-
|
|
321
|
-
const r = await bf.backfillTurn({ turnKey: 't1', sessionId: 's1', rolloutPath: '/x', salvageOnly: false })
|
|
322
|
-
|
|
323
|
-
expect(mocks.readGeneratedImageFiles).toHaveBeenCalled()
|
|
324
|
-
expect(mocks.readRollout).not.toHaveBeenCalled() // file source is authoritative when available
|
|
325
|
-
expect(r.delivered).toBe(1)
|
|
326
|
-
expect(mocks.deliverImageToNode.mock.calls[0][0]).toMatchObject({
|
|
327
|
-
nodeId: 'nodeA',
|
|
328
|
-
image: { callId: 'call_current' },
|
|
329
|
-
})
|
|
330
|
-
})
|
|
331
|
-
|
|
332
|
-
it('available but empty → no delivery, and does NOT fall back to rollout (mtime guard preserved)', async () => {
|
|
333
|
-
const bf = makeBackfiller()
|
|
334
|
-
observeExecuteAnchor(bf, 1, 't1', 's1', 'wf_real', 'nodeA')
|
|
335
|
-
mocks.readGeneratedImageFiles.mockReturnValue({ available: true, images: [] })
|
|
336
|
-
// Even though the rollout has an image, availability=true must lock us to the (empty) file source.
|
|
337
|
-
mocks.readRollout.mockReturnValue(rollout([IMG_A]))
|
|
338
|
-
|
|
339
|
-
const r = await bf.backfillTurn({ turnKey: 't1', sessionId: 's1', rolloutPath: '/x', salvageOnly: false })
|
|
340
|
-
|
|
341
|
-
expect(mocks.readRollout).not.toHaveBeenCalled()
|
|
342
|
-
expect(r).toEqual({ delivered: 0, orphaned: 0, dropped: 0 })
|
|
343
|
-
})
|
|
344
|
-
|
|
345
|
-
it('unavailable → falls back to rollout-scrape (older codex)', async () => {
|
|
346
|
-
const bf = makeBackfiller()
|
|
347
|
-
observeExecuteAnchor(bf, 1, 't1', 's1', 'wf_real', 'nodeA')
|
|
348
|
-
mocks.readGeneratedImageFiles.mockReturnValue({ available: false, images: [] })
|
|
349
|
-
mocks.readRollout.mockReturnValue(rollout([IMG_A]))
|
|
350
|
-
|
|
351
|
-
const r = await bf.backfillTurn({ turnKey: 't1', sessionId: 's1', rolloutPath: '/x', salvageOnly: false })
|
|
352
|
-
|
|
353
|
-
expect(mocks.readRollout).toHaveBeenCalledWith('/x')
|
|
354
|
-
expect(r.delivered).toBe(1)
|
|
355
|
-
})
|
|
356
|
-
|
|
357
|
-
it('exec-* file-source image (opaque call-id) is delivered exactly once (#1968)', async () => {
|
|
358
|
-
const bf = makeBackfiller()
|
|
359
|
-
observeExecuteAnchor(bf, 1, 't1', 's1', 'wf_real', 'nodeA')
|
|
360
|
-
mocks.readGeneratedImageFiles.mockReturnValue({
|
|
361
|
-
available: true,
|
|
362
|
-
images: [{ callId: 'exec-3eb2a29c', base64: 'iVBORw0KGgoAAAA1', mimeType: 'image/png' }],
|
|
363
|
-
failedCallIds: [],
|
|
364
|
-
})
|
|
365
|
-
|
|
366
|
-
const r = await bf.backfillTurn({ turnKey: 't1', sessionId: 's1', rolloutPath: '/x', salvageOnly: false })
|
|
367
|
-
|
|
368
|
-
expect(mocks.readRollout).not.toHaveBeenCalled()
|
|
369
|
-
expect(r.delivered).toBe(1)
|
|
370
|
-
expect(mocks.deliverImageToNode).toHaveBeenCalledTimes(1)
|
|
371
|
-
expect(mocks.deliverImageToNode.mock.calls[0][0]).toMatchObject({
|
|
372
|
-
nodeId: 'nodeA',
|
|
373
|
-
image: { callId: 'exec-3eb2a29c' },
|
|
374
|
-
})
|
|
375
|
-
})
|
|
376
|
-
|
|
377
|
-
it('unreadable fresh candidate is recovered from rollout by EXACT call-id only — no history bypass (#1968)', async () => {
|
|
378
|
-
const bf = makeBackfiller()
|
|
379
|
-
observeExecuteAnchor(bf, 1, 't1', 's1', 'wf_real', 'nodeA')
|
|
380
|
-
// File source: nothing readable, but one fresh candidate failed to read (torn write).
|
|
381
|
-
mocks.readGeneratedImageFiles.mockReturnValue({ available: true, images: [], failedCallIds: ['exec-torn'] })
|
|
382
|
-
// Rollout holds the failed id AND an unrelated historical image that must NOT be surfaced.
|
|
383
|
-
mocks.readRollout.mockReturnValue(rollout([['exec-torn', 'iVBORw0KGgoAAAA9'], ['ig_history', 'iVBORw0KGgoAAAA8']]))
|
|
384
|
-
|
|
385
|
-
const r = await bf.backfillTurn({ turnKey: 't1', sessionId: 's1', rolloutPath: '/x', salvageOnly: false })
|
|
386
|
-
|
|
387
|
-
expect(mocks.readRollout).toHaveBeenCalledWith('/x')
|
|
388
|
-
expect(r.delivered).toBe(1)
|
|
389
|
-
expect(mocks.deliverImageToNode).toHaveBeenCalledTimes(1)
|
|
390
|
-
expect(mocks.deliverImageToNode.mock.calls[0][0]).toMatchObject({ image: { callId: 'exec-torn' } })
|
|
391
|
-
// The historical image was in the rollout but is NOT in failedCallIds → never delivered or orphaned.
|
|
392
|
-
expect(mocks.addImageToCanvas).not.toHaveBeenCalled()
|
|
393
|
-
})
|
|
394
|
-
})
|
|
@@ -1,254 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @gemus/mcp-proxy fail-fast (#2068) — single-pass upstream-disconnect correlation.
|
|
3
|
-
*
|
|
4
|
-
* Pure logic; the socket is an injected `realFetch` returning controllable `Response`s. Cases that
|
|
5
|
-
* involve the final-safety timer use fake timers with advanceTimersByTimeAsync (lesson: fake timers
|
|
6
|
-
* + real reader.read() promises need the async advance to flush continuations).
|
|
7
|
-
*/
|
|
8
|
-
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
9
|
-
import { parseRequestIds, isCodexOriginated, createSseIdScanner, createFailFast, isJsonRpcResponse } from '../failFast.mjs'
|
|
10
|
-
|
|
11
|
-
const enc = new TextEncoder()
|
|
12
|
-
|
|
13
|
-
/** A response body that emits the given SSE frames then closes. */
|
|
14
|
-
function bodyOf(...frames: string[]): ReadableStream<Uint8Array> {
|
|
15
|
-
return new ReadableStream<Uint8Array>({
|
|
16
|
-
start(c) {
|
|
17
|
-
for (const f of frames) c.enqueue(enc.encode(f))
|
|
18
|
-
c.close()
|
|
19
|
-
},
|
|
20
|
-
})
|
|
21
|
-
}
|
|
22
|
-
/** A response body that never emits and never closes (zombie stream). */
|
|
23
|
-
function neverBody(): ReadableStream<Uint8Array> {
|
|
24
|
-
return new ReadableStream<Uint8Array>({ start() {} })
|
|
25
|
-
}
|
|
26
|
-
function sseRes(body: ReadableStream<Uint8Array> | null, init?: { status?: number; contentType?: string }) {
|
|
27
|
-
const status = init?.status ?? 200
|
|
28
|
-
return new Response(body, { status, headers: { 'content-type': init?.contentType ?? 'text/event-stream' } })
|
|
29
|
-
}
|
|
30
|
-
function resultFrame(id: number | string): string {
|
|
31
|
-
return `event: message\ndata: ${JSON.stringify({ jsonrpc: '2.0', id, result: { ok: true } })}\n\n`
|
|
32
|
-
}
|
|
33
|
-
function post(id: number | string, method = 'tools/call') {
|
|
34
|
-
return { method: 'POST', body: JSON.stringify({ jsonrpc: '2.0', id, method, params: {} }) }
|
|
35
|
-
}
|
|
36
|
-
async function drain(res: Response): Promise<void> {
|
|
37
|
-
const r = res.body!.getReader()
|
|
38
|
-
for (;;) { const { done } = await r.read(); if (done) break }
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
describe('parseRequestIds', () => {
|
|
42
|
-
it('extracts the id from a POST JSON-RPC request', () => {
|
|
43
|
-
expect(parseRequestIds(post(42))).toEqual([42])
|
|
44
|
-
})
|
|
45
|
-
it('extracts all ids from a batch', () => {
|
|
46
|
-
const body = JSON.stringify([
|
|
47
|
-
{ jsonrpc: '2.0', id: 1, method: 'a' },
|
|
48
|
-
{ jsonrpc: '2.0', id: 2, method: 'b' },
|
|
49
|
-
])
|
|
50
|
-
expect(parseRequestIds({ method: 'POST', body })).toEqual([1, 2])
|
|
51
|
-
})
|
|
52
|
-
it('returns [] for GET / DELETE / bodyless requests', () => {
|
|
53
|
-
expect(parseRequestIds({ method: 'GET' })).toEqual([])
|
|
54
|
-
expect(parseRequestIds({ method: 'DELETE' })).toEqual([])
|
|
55
|
-
expect(parseRequestIds({ method: 'POST' })).toEqual([])
|
|
56
|
-
})
|
|
57
|
-
it('returns [] for non-JSON body and for notifications (no id)', () => {
|
|
58
|
-
expect(parseRequestIds({ method: 'POST', body: 'not json' })).toEqual([])
|
|
59
|
-
expect(parseRequestIds({ method: 'POST', body: JSON.stringify({ jsonrpc: '2.0', method: 'x' }) })).toEqual([])
|
|
60
|
-
})
|
|
61
|
-
})
|
|
62
|
-
|
|
63
|
-
describe('isCodexOriginated', () => {
|
|
64
|
-
it.each([
|
|
65
|
-
[42, true],
|
|
66
|
-
['abc', true],
|
|
67
|
-
['gemus-bf-3', false],
|
|
68
|
-
['gemus-ping-9', false],
|
|
69
|
-
[null, false],
|
|
70
|
-
[undefined, false],
|
|
71
|
-
])('id=%s → %s', (id, expected) => {
|
|
72
|
-
expect(isCodexOriginated(id as never)).toBe(expected)
|
|
73
|
-
})
|
|
74
|
-
})
|
|
75
|
-
|
|
76
|
-
describe('isJsonRpcResponse', () => {
|
|
77
|
-
it('is true for a result/error response, false for a request or notification', () => {
|
|
78
|
-
expect(isJsonRpcResponse({ id: 1, result: {} })).toBe(true)
|
|
79
|
-
expect(isJsonRpcResponse({ id: 1, error: {} })).toBe(true)
|
|
80
|
-
expect(isJsonRpcResponse({ id: 1, method: 'elicitation/create', params: {} })).toBe(false) // server→client request
|
|
81
|
-
expect(isJsonRpcResponse({ method: 'notifications/x' })).toBe(false)
|
|
82
|
-
expect(isJsonRpcResponse(null)).toBe(false)
|
|
83
|
-
})
|
|
84
|
-
})
|
|
85
|
-
|
|
86
|
-
describe('createSseIdScanner', () => {
|
|
87
|
-
it('yields the id of a complete RESPONSE frame; ignores comment lines', () => {
|
|
88
|
-
const s = createSseIdScanner()
|
|
89
|
-
expect(s.push(enc.encode(': keepalive\n\n'))).toEqual([])
|
|
90
|
-
expect(s.push(enc.encode(resultFrame(7)))).toEqual([7])
|
|
91
|
-
})
|
|
92
|
-
it('does NOT yield a server→client REQUEST id (has a method) — avoids id-collision premature settle', () => {
|
|
93
|
-
const s = createSseIdScanner()
|
|
94
|
-
const reqFrame = `event: message\ndata: ${JSON.stringify({ jsonrpc: '2.0', id: 3, method: 'elicitation/create', params: {} })}\n\n`
|
|
95
|
-
expect(s.push(enc.encode(reqFrame))).toEqual([])
|
|
96
|
-
})
|
|
97
|
-
it('buffers a half-frame split across chunks until its boundary arrives', () => {
|
|
98
|
-
const s = createSseIdScanner()
|
|
99
|
-
const frame = resultFrame(5)
|
|
100
|
-
const mid = Math.floor(frame.length / 2)
|
|
101
|
-
expect(s.push(enc.encode(frame.slice(0, mid)))).toEqual([])
|
|
102
|
-
expect(s.push(enc.encode(frame.slice(mid)))).toEqual([5])
|
|
103
|
-
})
|
|
104
|
-
it('handles CRLF line endings (normalizes to LF)', () => {
|
|
105
|
-
const s = createSseIdScanner()
|
|
106
|
-
const crlf = `event: message\r\ndata: ${JSON.stringify({ jsonrpc: '2.0', id: 8, result: {} })}\r\n\r\n`
|
|
107
|
-
expect(s.push(enc.encode(crlf))).toEqual([8])
|
|
108
|
-
})
|
|
109
|
-
it('ignores non-JSON data payloads', () => {
|
|
110
|
-
const s = createSseIdScanner()
|
|
111
|
-
expect(s.push(enc.encode('data: not-json\n\n'))).toEqual([])
|
|
112
|
-
})
|
|
113
|
-
})
|
|
114
|
-
|
|
115
|
-
describe('createFailFast.fetch', () => {
|
|
116
|
-
it('injects a JSON-RPC error when the stream closes before the result', async () => {
|
|
117
|
-
const onLost = vi.fn()
|
|
118
|
-
const realFetch = vi.fn(async () => sseRes(bodyOf(': keepalive\n\n'))) // no result frame → lost
|
|
119
|
-
const ff = createFailFast({ onLost, realFetch })
|
|
120
|
-
|
|
121
|
-
const res = await ff.fetch('u', post(42))
|
|
122
|
-
await drain(res)
|
|
123
|
-
|
|
124
|
-
expect(onLost).toHaveBeenCalledTimes(1)
|
|
125
|
-
expect(onLost.mock.calls[0][0]).toBe(42)
|
|
126
|
-
expect(ff.failedIds.has('42')).toBe(true)
|
|
127
|
-
})
|
|
128
|
-
|
|
129
|
-
it('does NOT inject an error when the result crosses the wire before close', async () => {
|
|
130
|
-
const onLost = vi.fn()
|
|
131
|
-
const realFetch = vi.fn(async () => sseRes(bodyOf(resultFrame(42))))
|
|
132
|
-
const ff = createFailFast({ onLost, realFetch })
|
|
133
|
-
|
|
134
|
-
const res = await ff.fetch('u', post(42))
|
|
135
|
-
await drain(res)
|
|
136
|
-
|
|
137
|
-
expect(onLost).not.toHaveBeenCalled()
|
|
138
|
-
expect(ff.failedIds.has('42')).toBe(false)
|
|
139
|
-
})
|
|
140
|
-
|
|
141
|
-
it('fails fast immediately when realFetch throws before any response', async () => {
|
|
142
|
-
const onLost = vi.fn()
|
|
143
|
-
const realFetch = vi.fn(async () => { throw new Error('ECONNRESET') })
|
|
144
|
-
const ff = createFailFast({ onLost, realFetch })
|
|
145
|
-
|
|
146
|
-
await expect(ff.fetch('u', post(7))).rejects.toThrow('ECONNRESET')
|
|
147
|
-
expect(onLost).toHaveBeenCalledWith(7, expect.stringContaining('upstream fetch failed'))
|
|
148
|
-
expect(realFetch).toHaveBeenCalledTimes(1) // must NOT retry — a 2nd fetch could execute the tool
|
|
149
|
-
})
|
|
150
|
-
|
|
151
|
-
it('fails fast on a non-ok HTTP response', async () => {
|
|
152
|
-
const onLost = vi.fn()
|
|
153
|
-
const realFetch = vi.fn(async () => sseRes(null, { status: 404, contentType: 'application/json' }))
|
|
154
|
-
const ff = createFailFast({ onLost, realFetch })
|
|
155
|
-
|
|
156
|
-
await ff.fetch('u', post(7))
|
|
157
|
-
expect(onLost).toHaveBeenCalledWith(7, 'upstream HTTP 404')
|
|
158
|
-
})
|
|
159
|
-
|
|
160
|
-
it('passes GET and DELETE through untouched (no body → no monitoring, no onLost)', async () => {
|
|
161
|
-
const onLost = vi.fn()
|
|
162
|
-
const getRes = sseRes(bodyOf())
|
|
163
|
-
const realFetch = vi.fn(async () => getRes)
|
|
164
|
-
const ff = createFailFast({ onLost, realFetch })
|
|
165
|
-
|
|
166
|
-
const g = await ff.fetch('u', { method: 'GET' })
|
|
167
|
-
const d = await ff.fetch('u', { method: 'DELETE' })
|
|
168
|
-
expect(g).toBe(getRes)
|
|
169
|
-
expect(d).toBe(getRes)
|
|
170
|
-
expect(onLost).not.toHaveBeenCalled()
|
|
171
|
-
})
|
|
172
|
-
|
|
173
|
-
it('does not track internal gemus-bf-* / gemus-ping-* ids', async () => {
|
|
174
|
-
const onLost = vi.fn()
|
|
175
|
-
const realFetch = vi.fn(async () => sseRes(bodyOf(': keepalive\n\n'))) // closes with no result
|
|
176
|
-
const ff = createFailFast({ onLost, realFetch })
|
|
177
|
-
|
|
178
|
-
await drain(await ff.fetch('u', post('gemus-bf-1')))
|
|
179
|
-
await drain(await ff.fetch('u', post('gemus-ping-2')))
|
|
180
|
-
expect(onLost).not.toHaveBeenCalled()
|
|
181
|
-
})
|
|
182
|
-
|
|
183
|
-
it('degrades to plain passthrough if internal logic throws', async () => {
|
|
184
|
-
const onLost = vi.fn()
|
|
185
|
-
const badRes = { ok: true, status: 200, headers: { get() { throw new Error('boom') } }, body: null } as unknown as Response
|
|
186
|
-
const realFetch = vi.fn(async () => badRes)
|
|
187
|
-
const ff = createFailFast({ onLost, realFetch })
|
|
188
|
-
|
|
189
|
-
const res = await ff.fetch('u', post(1))
|
|
190
|
-
expect(res).toBe(badRes) // returns the response we ALREADY have — no re-fetch
|
|
191
|
-
expect(realFetch).toHaveBeenCalledTimes(1)
|
|
192
|
-
expect(onLost).not.toHaveBeenCalled()
|
|
193
|
-
ff.dispose()
|
|
194
|
-
})
|
|
195
|
-
|
|
196
|
-
it('isolates concurrent requests — disconnecting one does not affect the other', async () => {
|
|
197
|
-
const onLost = vi.fn()
|
|
198
|
-
const realFetch = vi
|
|
199
|
-
.fn()
|
|
200
|
-
.mockResolvedValueOnce(sseRes(bodyOf(': keepalive\n\n'))) // id 1 → dies with no result
|
|
201
|
-
.mockResolvedValueOnce(sseRes(bodyOf(resultFrame(2)))) // id 2 → delivers
|
|
202
|
-
const ff = createFailFast({ onLost, realFetch })
|
|
203
|
-
|
|
204
|
-
await drain(await ff.fetch('u', post(1)))
|
|
205
|
-
await drain(await ff.fetch('u', post(2)))
|
|
206
|
-
|
|
207
|
-
expect(onLost).toHaveBeenCalledTimes(1)
|
|
208
|
-
expect(onLost.mock.calls[0][0]).toBe(1)
|
|
209
|
-
})
|
|
210
|
-
|
|
211
|
-
describe('idle safety timeout', () => {
|
|
212
|
-
beforeEach(() => vi.useFakeTimers())
|
|
213
|
-
afterEach(() => vi.useRealTimers())
|
|
214
|
-
|
|
215
|
-
it('injects an error for a zombie connection that never emits a byte', async () => {
|
|
216
|
-
const onLost = vi.fn()
|
|
217
|
-
const realFetch = vi.fn(async () => sseRes(neverBody()))
|
|
218
|
-
const ff = createFailFast({ onLost, realFetch, idleTimeoutMs: 120_000 })
|
|
219
|
-
|
|
220
|
-
await ff.fetch('u', post(99)) // never emits → idle timer is never reset
|
|
221
|
-
expect(onLost).not.toHaveBeenCalled()
|
|
222
|
-
await vi.advanceTimersByTimeAsync(120_000)
|
|
223
|
-
expect(onLost).toHaveBeenCalledWith(99, expect.stringContaining('idle'))
|
|
224
|
-
ff.dispose()
|
|
225
|
-
})
|
|
226
|
-
|
|
227
|
-
it('does NOT kill a healthy-but-slow stream — heartbeat bytes reset the idle timer', async () => {
|
|
228
|
-
const onLost = vi.fn()
|
|
229
|
-
let ctl!: ReadableStreamDefaultController<Uint8Array>
|
|
230
|
-
const body = new ReadableStream<Uint8Array>({ start(c) { ctl = c } })
|
|
231
|
-
const realFetch = vi.fn(async () => sseRes(body))
|
|
232
|
-
const ff = createFailFast({ onLost, realFetch, idleTimeoutMs: 100 })
|
|
233
|
-
|
|
234
|
-
const res = await ff.fetch('u', post(1))
|
|
235
|
-
const reader = res.body!.getReader()
|
|
236
|
-
|
|
237
|
-
// Keepalive every 60ms (< 100ms idle) for 300ms total — each read touches → never fires.
|
|
238
|
-
for (let i = 0; i < 5; i++) {
|
|
239
|
-
ctl.enqueue(enc.encode(': keepalive\n\n'))
|
|
240
|
-
await reader.read()
|
|
241
|
-
await vi.advanceTimersByTimeAsync(60)
|
|
242
|
-
}
|
|
243
|
-
expect(onLost).not.toHaveBeenCalled() // survived 300ms > idleTimeout because bytes kept flowing
|
|
244
|
-
|
|
245
|
-
// Now the result arrives and the stream closes → still no fail-fast.
|
|
246
|
-
ctl.enqueue(enc.encode(resultFrame(1)))
|
|
247
|
-
await reader.read()
|
|
248
|
-
ctl.close()
|
|
249
|
-
await reader.read()
|
|
250
|
-
expect(onLost).not.toHaveBeenCalled()
|
|
251
|
-
ff.dispose()
|
|
252
|
-
})
|
|
253
|
-
})
|
|
254
|
-
})
|