@@ -15,7 +15,10 @@ import {
1515 requireRunnerPhaseRemainingMs ,
1616 resolveExpectedRunnerCacheMetadata ,
1717} from '../runner-cache-metadata.ts' ;
18+ import { captureDiagnostics } from './runner-session-fixtures.ts' ;
1819import { startFakeRunnerServer , type FakeRunnerServer } from './fake-runner-server.ts' ;
20+ import { resolveRunnerDetachDecision , RunnerCommandAccounting } from '../runner-session-types.ts' ;
21+ import { requireLifecycleSettlementRows } from './runner-swift-settlement-fixtures.ts' ;
1922
2023/**
2124 * The wiring regression the recovery suite cannot provide (#1644 review P1):
@@ -104,8 +107,7 @@ function makeRunnerSession(port: number, sessionId = `wiring:${port}`): RunnerSe
104107 testPromise : new Promise < ExecResult > ( ( ) => { } ) ,
105108 child : { pid : process . pid , exitCode : null } ,
106109 state : 'ready' ,
107- inFlightCommands : 0 ,
108- hasAbandonedCommands : false ,
110+ commandCharges : new RunnerCommandAccounting ( ) ,
109111 } ;
110112 return session ;
111113}
@@ -158,6 +160,186 @@ test.each(Object.values(LOST_RESPONSE_MUTATION_ROWS))(
158160 } ,
159161) ;
160162
163+ // #2965: an inline `status` probe answers while the command it probes may still be executing, so its
164+ // own reply must not clear the mutation's outstanding charge. The handoff verdict is asserted through
165+ // `resolveRunnerDetachDecision` — the exact gate `detachRunnerSessionForShutdown` consults. Rows come
166+ // from the runner journal's own state list, so a state the runner gains is a missing row rather than an
167+ // unruled verdict.
168+ const LOST_RESPONSE_HANDOFF_ROWS = requireLifecycleSettlementRows ( {
169+ completed : true ,
170+ failed : true ,
171+ accepted : false ,
172+ started : false ,
173+ notAccepted : false ,
174+ } ) ;
175+
176+ test . each ( LOST_RESPONSE_HANDOFF_ROWS ) (
177+ 'a lost mutation and status $lifecycleState leaves handoff $settlesCharge' ,
178+ async ( { lifecycleState, settlesCharge } ) => {
179+ server = await startFakeRunnerServer ( {
180+ tap : [ { kind : 'hangUp' } ] ,
181+ status : [ { kind : 'ok' , data : { lifecycleState } } ] ,
182+ } ) ;
183+ const session = seedSession ( server . port ) ;
184+
185+ // The mutation is refused, never replayed, whatever the terminal verdict — recovery keeps the
186+ // session and reports the command's state to the caller.
187+ await expect (
188+ runAppleRunnerCommand ( IOS_SIMULATOR , { command : 'tap' , x : 5 , y : 5 } ) ,
189+ ) . rejects . toThrow ( AppError ) ;
190+ assert . equal (
191+ server . requests . filter ( ( request ) => request . command === 'tap' ) . length ,
192+ 1 ,
193+ 'the mutation is sent once' ,
194+ ) ;
195+ // `notAccepted` is the journal's "never seen this id", which this daemon cannot read as a safe
196+ // terminal state: it keeps the invalidation path and its charge, while every state the journal
197+ // actually reports for the command keeps the session.
198+ assert . equal (
199+ invalidateRunnerSessionMock . mock . calls . length ,
200+ lifecycleState === 'notAccepted' ? 1 : 0 ,
201+ `${ lifecycleState } must ${ lifecycleState === 'notAccepted' ? '' : 'not ' } invalidate the session` ,
202+ ) ;
203+ assert . equal ( resolveRunnerDetachDecision ( session ) . detach , settlesCharge ) ;
204+ } ,
205+ ) ;
206+
207+ test ( 'a lost mutation answered by an inline status probe keeps handoff refused' , async ( ) => {
208+ // `started` with the runner reporting itself not busy is the legitimate state this issue is about:
209+ // the probe is served off the XCTest channel while the abandoned mutation keeps running.
210+ server = await startFakeRunnerServer ( {
211+ tap : [ { kind : 'hangUp' } ] ,
212+ status : [ { kind : 'ok' , data : { lifecycleState : 'started' , runnerMainThreadBusy : false } } ] ,
213+ } ) ;
214+ const session = seedSession ( server . port ) ;
215+
216+ await expect (
217+ runAppleRunnerCommand ( IOS_SIMULATOR , { command : 'tap' , x : 5 , y : 5 } ) ,
218+ ) . rejects . toThrow ( AppError ) ;
219+ assert . equal ( session . runnerMainThreadBusy , false ) ;
220+ assert . deepEqual ( resolveRunnerDetachDecision ( session ) , {
221+ detach : false ,
222+ reason : 'command_in_flight' ,
223+ } ) ;
224+ } ) ;
225+
226+ test ( 'an answered uptime health probe keeps handoff refused over an abandoned mutation' , async ( ) => {
227+ // The other inline probe, and the realistic #2965 producer: `prepareIosRunner` and prewarm send
228+ // `uptime` as background health traffic while a mutation is still owed. `status` only ever comes
229+ // from recovery, so pinning it alone would leave `uptime` free to start carrying a charge again —
230+ // whose answer would then forgive the mutation's residue on the serial-queue premise and hand a
231+ // runner off mid-mutation.
232+ server = await startFakeRunnerServer ( {
233+ tap : [ { kind : 'hangUp' } ] ,
234+ status : [ { kind : 'ok' , data : { lifecycleState : 'started' } } ] ,
235+ uptime : [
236+ { kind : 'ok' , data : { uptime : 12 } } ,
237+ { kind : 'ok' , data : { uptime : 13 } } ,
238+ ] ,
239+ } ) ;
240+ const session = seedSession ( server . port ) ;
241+
242+ await expect (
243+ runAppleRunnerCommand ( IOS_SIMULATOR , { command : 'tap' , x : 5 , y : 5 } ) ,
244+ ) . rejects . toThrow ( AppError ) ;
245+ // One charge: the mutation. The readiness preflight's probe is inline and carries no charge, so it
246+ // cannot arrive here as a second debt that some later answer could pay off (#2965).
247+ assert . equal ( session . commandCharges . outstandingChargeCount , 1 ) ;
248+
249+ await runAppleRunnerCommand ( IOS_SIMULATOR , { command : 'uptime' } ) ;
250+ assert . equal (
251+ server . requests . filter ( ( request ) => request . command === 'uptime' ) . length > 1 ,
252+ true ,
253+ 'this command is a second probe, answered by the same session' ,
254+ ) ;
255+ // The probe answered and owed nothing, so the mutation is still the only thing owed and the runner
256+ // stays on the kill path rather than being handed off mid-mutation.
257+ assert . equal ( session . commandCharges . outstandingChargeCount , 1 ) ;
258+ assert . equal ( session . commandCharges . hasAbandonedCharges , true ) ;
259+ assert . deepEqual ( resolveRunnerDetachDecision ( session ) , {
260+ detach : false ,
261+ reason : 'command_in_flight' ,
262+ } ) ;
263+ } ) ;
264+
265+ test ( 'terminal status for a lost mutation charges the next lost mutation afresh' , async ( ) => {
266+ server = await startFakeRunnerServer ( {
267+ tap : [ { kind : 'hangUp' } , { kind : 'hangUp' } ] ,
268+ status : [
269+ { kind : 'ok' , data : { lifecycleState : 'completed' } } ,
270+ { kind : 'ok' , data : { lifecycleState : 'started' } } ,
271+ ] ,
272+ } ) ;
273+ const session = seedSession ( server . port ) ;
274+
275+ const first = await captureDiagnostics ( async ( ) => {
276+ await expect (
277+ runAppleRunnerCommand ( IOS_SIMULATOR , { command : 'tap' , x : 5 , y : 5 } ) ,
278+ ) . rejects . toThrow ( AppError ) ;
279+ } ) ;
280+ // The daemon log has to say whether terminal evidence actually paid the debt, because a refused
281+ // settlement and a settled one both leave the runner answering (#2965).
282+ assert . match ( first , / " a b a n d o n e d C h a r g e S e t t l e d " : t r u e / ) ;
283+ assert . equal ( resolveRunnerDetachDecision ( session ) . detach , true ) ;
284+
285+ // The settlement is this command's, not a latch: a second mutation that loses its response is
286+ // charged again and only its own terminal verdict frees the runner.
287+ await expect (
288+ runAppleRunnerCommand ( IOS_SIMULATOR , { command : 'tap' , x : 5 , y : 5 } ) ,
289+ ) . rejects . toThrow ( AppError ) ;
290+ assert . equal ( resolveRunnerDetachDecision ( session ) . detach , false ) ;
291+ } ) ;
292+
293+ test ( 'terminal status with the runner reporting busy still refuses handoff' , async ( ) => {
294+ server = await startFakeRunnerServer ( {
295+ tap : [ { kind : 'hangUp' } ] ,
296+ status : [ { kind : 'ok' , data : { lifecycleState : 'completed' , runnerMainThreadBusy : true } } ] ,
297+ } ) ;
298+ const session = seedSession ( server . port ) ;
299+
300+ await expect (
301+ runAppleRunnerCommand ( IOS_SIMULATOR , { command : 'tap' , x : 5 , y : 5 } ) ,
302+ ) . rejects . toThrow ( AppError ) ;
303+
304+ // The journal verdict discharged the charge, yet the runner's own occupancy report keeps it on the
305+ // kill path: a terminal state never bypasses the busy gate (#2552).
306+ assert . equal ( session . runnerMainThreadBusy , true ) ;
307+ assert . deepEqual ( resolveRunnerDetachDecision ( session ) , {
308+ detach : false ,
309+ reason : 'main_thread_occupied' ,
310+ } ) ;
311+ } ) ;
312+
313+ test ( 'a lost mutation whose status probe fails keeps the runner on the kill path' , async ( ) => {
314+ server = await startFakeRunnerServer ( {
315+ tap : [ { kind : 'hangUp' } ] ,
316+ status : [ { kind : 'hangUp' } ] ,
317+ } ) ;
318+ const session = seedSession ( server . port ) ;
319+
320+ await expect (
321+ runAppleRunnerCommand ( IOS_SIMULATOR , { command : 'tap' , x : 5 , y : 5 } ) ,
322+ ) . rejects . toThrow ( AppError ) ;
323+ assert . equal ( resolveRunnerDetachDecision ( session ) . detach , false ) ;
324+ } ) ;
325+
326+ test ( 'a healthy command after a settled abandoned charge returns the runner to handoff-eligible' , async ( ) => {
327+ server = await startFakeRunnerServer ( {
328+ tap : [ { kind : 'hangUp' } ] ,
329+ status : [ { kind : 'ok' , data : { lifecycleState : 'completed' } } ] ,
330+ snapshot : [ { kind : 'ok' , data : { nodes : [ ] } } ] ,
331+ } ) ;
332+ const session = seedSession ( server . port ) ;
333+
334+ await expect (
335+ runAppleRunnerCommand ( IOS_SIMULATOR , { command : 'tap' , x : 5 , y : 5 } ) ,
336+ ) . rejects . toThrow ( AppError ) ;
337+ assert . equal ( resolveRunnerDetachDecision ( session ) . detach , true ) ;
338+
339+ await runAppleRunnerCommand ( IOS_SIMULATOR , { command : 'snapshot' } ) ;
340+ assert . equal ( resolveRunnerDetachDecision ( session ) . detach , true ) ;
341+ } ) ;
342+
161343test ( 'a runner that reports the command failed surfaces that failure, not the transport error' , async ( ) => {
162344 server = await startFakeRunnerServer ( {
163345 tap : [ { kind : 'hangUp' } ] ,
0 commit comments