@@ -2,12 +2,29 @@ import * as assert from 'assert';
22import * as sinon from 'sinon' ;
33import * as fs from 'fs' ;
44import * as path from 'path' ;
5- import { CancellationToken , TestController , TestItem , Uri , Range , Position } from 'vscode' ;
6- import { writeTestIdsFile , populateTestTree } from '../../../client/testing/testController/common/utils' ;
5+ import { CancellationToken , CancellationTokenSource , TestController , TestItem , Uri , Range , Position } from 'vscode' ;
6+ import {
7+ Emitter ,
8+ Event ,
9+ MessageReader ,
10+ PartialMessageInfo ,
11+ Disposable as RpcDisposable ,
12+ DataCallback ,
13+ } from 'vscode-jsonrpc' ;
14+ import { Message } from 'vscode-jsonrpc' ;
15+ import {
16+ writeTestIdsFile ,
17+ populateTestTree ,
18+ startRunResultNamedPipe ,
19+ awaitDeferredWithTimeout ,
20+ } from '../../../client/testing/testController/common/utils' ;
21+ import { createDeferred , Deferred } from '../../../client/common/utils/async' ;
22+ import * as namedPipes from '../../../client/common/pipes/namedPipes' ;
723import { EXTENSION_ROOT_DIR } from '../../../client/constants' ;
824import {
925 DiscoveredTestNode ,
1026 DiscoveredTestItem ,
27+ ExecutionTestPayload ,
1128 ITestResultResolver ,
1229} from '../../../client/testing/testController/common/types' ;
1330import { RunTestTag , DebugTestTag } from '../../../client/testing/testController/common/testItemUtilities' ;
@@ -752,3 +769,192 @@ suite('populateTestTree tests', () => {
752769 assert . deepStrictEqual ( mockTestItem2 . range , new Range ( new Position ( 6 , 0 ) , new Position ( 7 , 0 ) ) ) ;
753770 } ) ;
754771} ) ;
772+
773+ suite ( 'startRunResultNamedPipe drain-on-cancel tests' , ( ) => {
774+ let sandbox : sinon . SinonSandbox ;
775+ let createReaderPipeStub : sinon . SinonStub ;
776+
777+ // Minimal `MessageReader` fake exposing only what `startRunResultNamedPipe` uses.
778+ class FakeMessageReader implements MessageReader {
779+ private _onClose = new Emitter < void > ( ) ;
780+
781+ private _onError = new Emitter < Error > ( ) ;
782+
783+ private _onPartialMessage = new Emitter < PartialMessageInfo > ( ) ;
784+
785+ private _callback : DataCallback | undefined ;
786+
787+ public disposed = false ;
788+
789+ public onError : Event < Error > = this . _onError . event ;
790+
791+ public onClose : Event < void > = this . _onClose . event ;
792+
793+ public onPartialMessage : Event < PartialMessageInfo > = this . _onPartialMessage . event ;
794+
795+ public listen ( callback : DataCallback ) : RpcDisposable {
796+ this . _callback = callback ;
797+ return {
798+ dispose : ( ) => {
799+ this . _callback = undefined ;
800+ } ,
801+ } ;
802+ }
803+
804+ public dispose ( ) : void {
805+ this . disposed = true ;
806+ this . _onClose . dispose ( ) ;
807+ this . _onError . dispose ( ) ;
808+ this . _onPartialMessage . dispose ( ) ;
809+ }
810+
811+ // Test helpers.
812+ public emit ( message : Message ) : void {
813+ this . _callback ?.( message ) ;
814+ }
815+
816+ public hasListener ( ) : boolean {
817+ return this . _callback !== undefined ;
818+ }
819+
820+ public fireClose ( ) : void {
821+ this . _onClose . fire ( ) ;
822+ }
823+ }
824+
825+ setup ( ( ) => {
826+ sandbox = sinon . createSandbox ( ) ;
827+ } ) ;
828+
829+ teardown ( ( ) => {
830+ sandbox . restore ( ) ;
831+ } ) ;
832+
833+ function makeMessage ( payload : Partial < ExecutionTestPayload > ) : Message {
834+ // Fill in required ExecutionTestPayload fields so tests exercise a shape close
835+ // to what real runners send and don't drift from the schema over time.
836+ const full : ExecutionTestPayload = {
837+ cwd : '' ,
838+ status : 'success' ,
839+ error : '' ,
840+ ...payload ,
841+ } ;
842+ return ( { jsonrpc : '2.0' , params : full } as unknown ) as Message ;
843+ }
844+
845+ test ( 'cancellation alone does NOT resolve deferredTillServerClose and does NOT detach the listener (drain not interrupted)' , async ( ) => {
846+ const reader = new FakeMessageReader ( ) ;
847+ createReaderPipeStub = sandbox . stub ( namedPipes , 'createReaderPipe' ) . resolves ( reader ) ;
848+
849+ const received : ExecutionTestPayload [ ] = [ ] ;
850+ const deferredTillServerClose : Deferred < void > = createDeferred < void > ( ) ;
851+ const cancelSource = new CancellationTokenSource ( ) ;
852+
853+ await startRunResultNamedPipe ( ( payload ) => received . push ( payload ) , deferredTillServerClose , cancelSource . token ) ;
854+
855+ assert . ok ( createReaderPipeStub . calledOnce , 'createReaderPipe should be called once' ) ;
856+ assert . ok ( reader . hasListener ( ) , 'reader should have a listener registered before cancel' ) ;
857+
858+ // Trigger cancellation.
859+ cancelSource . cancel ( ) ;
860+
861+ // Yield to let any synchronous-then-microtask handlers run.
862+ await new Promise ( ( r ) => setImmediate ( r ) ) ;
863+
864+ assert . strictEqual (
865+ reader . disposed ,
866+ false ,
867+ 'reader must NOT be disposed by cancellation alone (otherwise buffered data would be lost)' ,
868+ ) ;
869+ assert . ok ( reader . hasListener ( ) , 'data listener must remain attached after cancel so the drain can continue' ) ;
870+ assert . strictEqual (
871+ ( deferredTillServerClose as Deferred < void > ) . completed ,
872+ false ,
873+ 'deferredTillServerClose must NOT resolve on cancellation; it should only resolve when the pipe closes' ,
874+ ) ;
875+
876+ cancelSource . dispose ( ) ;
877+ } ) ;
878+
879+ test ( 'data emitted after cancellation is still delivered to the callback (drain works)' , async ( ) => {
880+ const reader = new FakeMessageReader ( ) ;
881+ sandbox . stub ( namedPipes , 'createReaderPipe' ) . resolves ( reader ) ;
882+
883+ const received : ExecutionTestPayload [ ] = [ ] ;
884+ const deferredTillServerClose : Deferred < void > = createDeferred < void > ( ) ;
885+ const cancelSource = new CancellationTokenSource ( ) ;
886+
887+ await startRunResultNamedPipe ( ( payload ) => received . push ( payload ) , deferredTillServerClose , cancelSource . token ) ;
888+
889+ // Simulate the debug-path race: cancel fires while results are still buffered.
890+ cancelSource . cancel ( ) ;
891+ await new Promise ( ( r ) => setImmediate ( r ) ) ;
892+
893+ // Buffered messages arrive after cancellation.
894+ reader . emit ( makeMessage ( { cwd : 'a' } ) ) ;
895+ reader . emit ( makeMessage ( { cwd : 'b' } ) ) ;
896+
897+ assert . strictEqual ( received . length , 2 , 'messages emitted after cancel must still reach the callback' ) ;
898+ assert . deepStrictEqual (
899+ received . map ( ( p ) => p . cwd ) ,
900+ [ 'a' , 'b' ] ,
901+ 'all buffered results delivered in order' ,
902+ ) ;
903+
904+ // Subprocess closes its end of the pipe -> onClose fires -> dispose.
905+ reader . fireClose ( ) ;
906+ await deferredTillServerClose . promise ;
907+
908+ assert . strictEqual ( reader . disposed , true , 'reader disposed via onClose path' ) ;
909+
910+ cancelSource . dispose ( ) ;
911+ } ) ;
912+
913+ test ( 'reader.onClose resolves deferredTillServerClose and disposes the reader (natural completion path, no cancellation)' , async ( ) => {
914+ const reader = new FakeMessageReader ( ) ;
915+ sandbox . stub ( namedPipes , 'createReaderPipe' ) . resolves ( reader ) ;
916+
917+ const deferredTillServerClose : Deferred < void > = createDeferred < void > ( ) ;
918+
919+ await startRunResultNamedPipe (
920+ ( ) => {
921+ /* no-op */
922+ } ,
923+ deferredTillServerClose ,
924+ undefined ,
925+ ) ;
926+
927+ assert . strictEqual (
928+ ( deferredTillServerClose as Deferred < void > ) . completed ,
929+ false ,
930+ 'deferred unresolved before close' ,
931+ ) ;
932+
933+ reader . fireClose ( ) ;
934+ await deferredTillServerClose . promise ;
935+
936+ assert . strictEqual ( reader . disposed , true , 'reader disposed when onClose fires' ) ;
937+ } ) ;
938+ } ) ;
939+
940+ suite ( 'awaitDeferredWithTimeout' , ( ) => {
941+ test ( 'resolves promptly when the deferred resolves before the timeout' , async ( ) => {
942+ const deferred = createDeferred < void > ( ) ;
943+ const started = Date . now ( ) ;
944+ const waiter = awaitDeferredWithTimeout ( deferred , 5000 ) ;
945+ setTimeout ( ( ) => deferred . resolve ( ) , 10 ) ;
946+ await waiter ;
947+ const elapsed = Date . now ( ) - started ;
948+ assert . ok ( elapsed < 1000 , `should resolve well before timeout, took ${ elapsed } ms` ) ;
949+ } ) ;
950+
951+ test ( 'resolves after the timeout when the deferred never settles (no hang)' , async ( ) => {
952+ const deferred = createDeferred < void > ( ) ;
953+ const started = Date . now ( ) ;
954+ await awaitDeferredWithTimeout ( deferred , 50 ) ;
955+ const elapsed = Date . now ( ) - started ;
956+ assert . ok ( elapsed >= 50 , `should wait at least the timeout, took ${ elapsed } ms` ) ;
957+ assert . ok ( elapsed < 2000 , `should not hang well beyond timeout, took ${ elapsed } ms` ) ;
958+ assert . strictEqual ( deferred . completed , false , 'underlying deferred remains unresolved' ) ;
959+ } ) ;
960+ } ) ;
0 commit comments