1- import { Deferred , Effect , Semaphore } from 'effect' ;
1+ import { Cause , Deferred , Effect , Exit , Result , Semaphore } from 'effect' ;
22import { resolve } from 'node:path' ;
33
44import { freezeDiagnostics , hasErrors } from '../core/diagnostics.ts' ;
55import { runPromise , runSync } from '../effect/boundary.ts' ;
6- import { liftPromise } from '../effect/lift.ts' ;
6+ import { liftPromise , liftTry } from '../effect/lift.ts' ;
77import type { Diagnostic } from '../core/diagnostics.ts' ;
88import { ArtifactService , type ArtifactEpochResult , type FailedArtifactEpochResult } from './artifacts/artifact-service.ts' ;
99import { DiagnosticService , type DiagnosticReport } from './diagnostic-service.ts' ;
@@ -199,7 +199,6 @@ const artifactStatusFor = (
199199export class DevCoordinator {
200200 readonly #acquireLock: ( options : DevLockOptions ) => Promise < DevLockHandle > ;
201201 readonly #artifactService: ArtifactBuilder ;
202- readonly #cancelStartup: ( ) => void ;
203202 readonly #createAttemptId: ( ) => string ;
204203 readonly #createWatcher: ( options : ProjectWatcherOptions ) => DevelopmentWatcher ;
205204 readonly #diagnosticService: AffectedFileDiagnostics ;
@@ -216,7 +215,11 @@ export class DevCoordinator {
216215 /** Serializes build passes; admission below guarantees one holder, the permit makes the invariant structural. */
217216 readonly #buildPermit: Semaphore . Semaphore = runSync ( Semaphore . make ( 1 ) ) ;
218217 readonly #startRebuildToken = Symbol ( 'DevCoordinator initial rebuild' ) ;
219- readonly #startupCancellation: Promise < void > ;
218+ /**
219+ * Fails once `close()` cancels a startup that is still blocked before its
220+ * first build; every startup step races against it (`#awaitStartup`).
221+ */
222+ readonly #startupClosed: Deferred . Deferred < never , Error > = runSync ( Deferred . make < never , Error > ( ) ) ;
220223 #activeEpoch: ArtifactEpoch | undefined ;
221224 #closing = false ;
222225 #closePromise: Promise < void > | undefined ;
@@ -236,11 +239,6 @@ export class DevCoordinator {
236239 this . #acquireLock = options . acquireLock ?? acquireDevLock ;
237240 this . #epochStore = options . epochStore ?? new EpochStore ( { projectRoot : this . #root } ) ;
238241 this . #artifactService = options . artifactService ?? new ArtifactService ( { epochStore : this . #epochStore } ) ;
239- let cancelStartup : ( ) => void = ( ) => undefined ;
240- this . #startupCancellation = new Promise < void > ( ( resolvePromise ) => {
241- cancelStartup = resolvePromise ;
242- } ) ;
243- this . #cancelStartup = cancelStartup ;
244242 this . #createAttemptId = options . createAttemptId ?? ( ( ) => crypto . randomUUID ( ) ) ;
245243 this . #createWatcher = options . createWatcher ?? ( ( watcherOptions ) => new ProjectWatcher ( watcherOptions ) ) ;
246244 this . #diagnosticService = options . diagnosticService ?? new DiagnosticService ( { root : this . #root } ) ;
@@ -264,7 +262,7 @@ export class DevCoordinator {
264262 async start ( ) : Promise < DevSession > {
265263 if ( this . #startPromise !== undefined ) return this . #startPromise;
266264 if ( this . #closing) throw new Error ( 'DevCoordinator is closed.' ) ;
267- this . #startPromise = this . #start ( ) ;
265+ this . #startPromise = runPromise ( this . #startEffect ( ) ) ;
268266 return this . #startPromise;
269267 }
270268
@@ -312,68 +310,89 @@ export class DevCoordinator {
312310 return this . #closePromise;
313311 }
314312
315- async #start( ) : Promise < DevSession > {
316- try {
317- await this . #acquireStartupLock( ) ;
318- this . #assertOpen( ) ;
319- await this . #awaitStartup( this . #epochStore. recoverStaging ( ) ) ;
320- this . #assertOpen( ) ;
321- this . #activeEpoch = await this . #awaitStartup( this . #epochStore. readActiveEpoch ( ) ) ;
322- this . #assertOpen( ) ;
323- const projectIgnoreRules = await this . #awaitStartup( readProjectIgnoreRules ( this . #root) ) ;
324- this . #assertOpen( ) ;
325- this . #watcher = this . #createWatcher( {
313+ /**
314+ * Startup as one Effect: every blocking step races the close signal
315+ * (`#awaitStartup`), and a failed startup releases whatever it acquired —
316+ * watcher and lock concurrently, outcomes ignored — before re-raising the
317+ * original error. Sync steps are lifted so a throw is a typed failure the
318+ * cleanup handler sees, never a defect that skips it.
319+ */
320+ #startEffect( ) : Effect . Effect < DevSession , unknown > {
321+ const startup = Effect . gen ( { self : this } , function * ( this : DevCoordinator ) {
322+ yield * this . #acquireStartupLock( ) ;
323+ yield * this . #assertOpenEffect( ) ;
324+ yield * this . #awaitStartup( ( ) => this . #epochStore. recoverStaging ( ) ) ;
325+ yield * this . #assertOpenEffect( ) ;
326+ this . #activeEpoch = yield * this . #awaitStartup( ( ) => this . #epochStore. readActiveEpoch ( ) ) ;
327+ yield * this . #assertOpenEffect( ) ;
328+ const projectIgnoreRules = yield * this . #awaitStartup( ( ) => readProjectIgnoreRules ( this . #root) ) ;
329+ yield * this . #assertOpenEffect( ) ;
330+ const watcher = yield * liftTry ( ( ) => this . #createWatcher( {
326331 ignoredPaths : this . #ignoredPaths,
327332 isIgnored : ( source ) => isProjectPathIgnored ( projectIgnoreRules , this . #root, source ) ,
328333 now : this . #now,
329334 onInvalidation : async ( invalidation ) => this . rebuild ( invalidation ) ,
330335 outputPaths : this . #outputPaths,
331336 root : this . #root,
332- } ) ;
333- await this . #awaitStartup( this . #watcher. ready ?.( ) ?? Promise . resolve ( ) ) ;
334- this . #assertOpen( ) ;
335- await this . #rebuild( nowInvalidation ( this . #now, 'initial' , [ ] ) , this . #startRebuildToken) ;
337+ } ) ) ;
338+ this . #watcher = watcher ;
339+ yield * this . #awaitStartup( ( ) => watcher . ready ?.( ) ?? Promise . resolve ( ) ) ;
340+ yield * this . #assertOpenEffect( ) ;
341+ yield * liftPromise ( ( ) => this . #rebuild( nowInvalidation ( this . #now, 'initial' , [ ] ) , this . #startRebuildToken) ) ;
336342 const session : DevSession = Object . freeze ( {
337343 close : ( ) => this . close ( ) ,
338344 status : ( ) => this . status ( ) ,
339345 } ) ;
340346 this . #session = session ;
341347 return session ;
342- } catch ( error ) {
343- await Promise . allSettled ( [
344- this . #releaseWatcher( ) ,
345- this . #releaseLock( ) ,
346- ] ) ;
348+ } ) ;
349+ return startup . pipe ( Effect . catch ( ( error ) => Effect . gen ( { self : this } , function * ( this : DevCoordinator ) {
350+ yield * Effect . forEach (
351+ [ ( ) => this . #releaseWatcher( ) , ( ) => this . #releaseLock( ) ] ,
352+ ( release ) => Effect . exit ( liftPromise ( release ) ) ,
353+ { concurrency : 'unbounded' } ,
354+ ) ;
347355 this . #watcher = undefined ;
348356 this . #lock = undefined ;
349- throw error ;
350- }
357+ return yield * Effect . fail ( error ) ;
358+ } ) ) ) ;
351359 }
352360
353- #assertOpen( ) : void {
354- if ( this . #closing) throw new Error ( 'DevCoordinator is closed.' ) ;
361+ #assertOpenEffect( ) : Effect . Effect < void , Error > {
362+ return Effect . suspend ( ( ) => this . #closing
363+ ? Effect . fail ( new Error ( 'DevCoordinator is closed.' ) )
364+ : Effect . void ) ;
355365 }
356366
357- async #acquireStartupLock( ) : Promise < void > {
358- const acquisition = this . #acquireLock( { projectRoot : this . #root } ) ;
359- try {
360- this . #lock = await this . #awaitStartup( acquisition ) ;
361- } catch ( error ) {
362- void acquisition . then (
363- ( lock ) => lock . close ( ) . catch ( ( ) => undefined ) ,
364- ( ) => undefined ,
367+ /**
368+ * A lock that resolves after startup was cancelled is released, not kept:
369+ * the acquisition is started once, raced against close, and drained when
370+ * it loses.
371+ */
372+ #acquireStartupLock( ) : Effect . Effect < void , unknown > {
373+ return Effect . suspend ( ( ) => {
374+ const acquisition = this . #acquireLock( { projectRoot : this . #root } ) ;
375+ return this . #awaitStartup( ( ) => acquisition ) . pipe (
376+ Effect . flatMap ( ( lock ) => Effect . sync ( ( ) => {
377+ this . #lock = lock ;
378+ } ) ) ,
379+ Effect . tapError ( ( ) => Effect . sync ( ( ) => {
380+ void acquisition . then (
381+ ( lock ) => lock . close ( ) . catch ( ( ) => undefined ) ,
382+ ( ) => undefined ,
383+ ) ;
384+ } ) ) ,
365385 ) ;
366- throw error ;
367- }
386+ } ) ;
387+ }
388+
389+ /** One startup step raced against `close()`; the loser is interrupted. */
390+ #awaitStartup< T > ( operation : ( ) => Promise < T > ) : Effect . Effect < T , unknown > {
391+ return Effect . raceFirst ( liftPromise ( operation ) , Deferred . await ( this . #startupClosed) ) ;
368392 }
369393
370- async #awaitStartup< T > ( operation : Promise < T > ) : Promise < T > {
371- return Promise . race ( [
372- operation ,
373- this . #startupCancellation. then ( ( ) => {
374- throw new Error ( 'DevCoordinator is closed.' ) ;
375- } ) ,
376- ] ) ;
394+ #cancelStartup( ) : void {
395+ runSync ( Deferred . fail ( this . #startupClosed, new Error ( 'DevCoordinator is closed.' ) ) ) ;
377396 }
378397
379398 #releaseLock( ) : Promise < void > {
@@ -399,7 +418,7 @@ export class DevCoordinator {
399418 */
400419 #startBuild( invalidation : Invalidation ) : Promise < ArtifactEpochResult > {
401420 const current = runPromise ( this . #buildPermit. withPermit (
402- liftPromise ( ( ) => this . #performBuild( invalidation ) ) . pipe (
421+ this . #performBuild( invalidation ) . pipe (
403422 Effect . onExit ( ( ) => Effect . sync ( ( ) => this . #drainQueuedBuild( ) ) ) ,
404423 ) ,
405424 ) ) ;
@@ -473,53 +492,63 @@ export class DevCoordinator {
473492 return result ;
474493 }
475494
476- async #performBuild( invalidation : Invalidation ) : Promise < ArtifactEpochResult > {
477- let prepared : PreparedProject ;
478- try {
479- const initial = this . #nextPreparedProject;
480- this . #nextPreparedProject = undefined ;
481- prepared = initial ?? await this . #projectService. prepare ( this . #prepareCommand) ;
482- } catch ( error ) {
483- const source = withDiagnostics ( this . #status. source , [ phaseDiagnostic ( 'prepare' , error ) ] ) ;
495+ /**
496+ * One serialized build pass. Only the leaf I/O is lifted — prepare, the
497+ * prepared-project hook, lint, the artifact build, and the package build —
498+ * and each phase's failure is exposed as a `Result` so it completes the
499+ * attempt as a failed build result. Status and event bookkeeping stays
500+ * synchronous inside the fiber; the program itself never fails.
501+ */
502+ readonly #performBuild = Effect . fnUntraced ( function * (
503+ this : DevCoordinator ,
504+ invalidation : Invalidation ,
505+ ) : Effect . fn . Return < ArtifactEpochResult , unknown > {
506+ const initial = this . #nextPreparedProject;
507+ this . #nextPreparedProject = undefined ;
508+ const preparation = yield * Effect . result ( initial === undefined
509+ ? liftPromise ( ( ) => this . #projectService. prepare ( this . #prepareCommand) )
510+ : Effect . succeed ( initial ) ) ;
511+ if ( Result . isFailure ( preparation ) ) {
512+ const source = withDiagnostics ( this . #status. source , [ phaseDiagnostic ( 'prepare' , preparation . failure ) ] ) ;
484513 return this . #completeFailure( this . #beginBuild( invalidation , source ) , source , source . diagnostics ) ;
485514 }
515+ const prepared = preparation . success ;
486516
487517 this . #watcher?. addOutputPaths ?.( [ prepared . artifactDistPath , ...prepared . outputRoots ] ) ;
488518 const running = this . #beginBuild( invalidation , prepared . source ) ;
489- try {
490- await this . #onPreparedProject?.( prepared ) ;
491- } catch ( error ) {
492- const source = withDiagnostics ( prepared . source , [ phaseDiagnostic ( 'prepare' , error ) ] ) ;
519+ const onPrepared = this . #onPreparedProject;
520+ const hook = yield * Effect . result ( onPrepared === undefined
521+ ? Effect . void
522+ : liftPromise ( ( ) => onPrepared ( prepared ) ) ) ;
523+ if ( Result . isFailure ( hook ) ) {
524+ const source = withDiagnostics ( prepared . source , [ phaseDiagnostic ( 'prepare' , hook . failure ) ] ) ;
493525 return this . #completeFailure( running , source , source . diagnostics ) ;
494526 }
495- let lintDiagnostics : readonly Diagnostic [ ] ;
496- try {
497- const report = await this . #diagnosticService. lint ( invalidation . paths ) ;
498- lintDiagnostics = freezeDiagnostics ( report . diagnostics ) ;
499- } catch ( error ) {
500- const source = withDiagnostics ( prepared . source , [ phaseDiagnostic ( 'lint' , error ) ] ) ;
527+ const lint = yield * Effect . result ( liftPromise ( ( ) => this . #diagnosticService. lint ( invalidation . paths ) ) ) ;
528+ if ( Result . isFailure ( lint ) ) {
529+ const source = withDiagnostics ( prepared . source , [ phaseDiagnostic ( 'lint' , lint . failure ) ] ) ;
501530 return this . #completeFailure( running , source , source . diagnostics ) ;
502531 }
532+ const lintDiagnostics : readonly Diagnostic [ ] = freezeDiagnostics ( lint . success . diagnostics ) ;
503533
504534 const source = withDiagnostics ( prepared . source , lintDiagnostics ) ;
505535 if ( hasErrors ( lintDiagnostics ) ) {
506536 return this . #completeFailure( running , source , source . diagnostics ) ;
507537 }
508538
509- let result : ArtifactEpochResult ;
510- try {
511- result = await this . #artifactService. build ( prepared ) ;
512- } catch ( error ) {
539+ const built = yield * Effect . result ( liftPromise ( ( ) => this . #artifactService. build ( prepared ) ) ) ;
540+ if ( Result . isFailure ( built ) ) {
513541 return this . #completeFailure( running , source , [
514542 ...source . diagnostics ,
515- phaseDiagnostic ( 'artifact' , error ) ,
543+ phaseDiagnostic ( 'artifact' , built . failure ) ,
516544 ] ) ;
517545 }
546+ const result = built . success ;
518547 // The package build (bin/lib) rebuilds inside the same serialized pass,
519548 // after the artifact epoch committed: its failure never invalidates the
520549 // epoch and surfaces as warning diagnostics on the succeeded attempt.
521550 const packageDiagnostics = result . outcome === 'succeeded'
522- ? ( await this . #packageBuildService. build ( prepared , invalidation ) ) . diagnostics
551+ ? ( yield * liftPromise ( ( ) => this . #packageBuildService. build ( prepared , invalidation ) ) ) . diagnostics
523552 : Object . freeze ( [ ] ) ;
524553 const diagnostics = freezeDiagnostics ( [ ...lintDiagnostics , ...result . diagnostics , ...packageDiagnostics ] ) ;
525554 if ( result . outcome === 'succeeded' ) {
@@ -546,9 +575,16 @@ export class DevCoordinator {
546575 return Object . freeze ( { diagnostics, epoch : result . epoch , outcome : 'succeeded' } ) ;
547576 }
548577 return this . #completeFailure( running , source , diagnostics ) ;
549- }
578+ } ) ;
550579
551- async #close( ) : Promise < void > {
580+ /**
581+ * Shutdown as one Effect: wait for the in-flight build or startup to
582+ * settle, then release every resource concurrently, capturing each `Exit`
583+ * so no failure short-circuits another release. Every failure is reported
584+ * together as `DevCoordinatorCloseError` (build first, then resources in
585+ * declaration order).
586+ */
587+ #close( ) : Promise < void > {
552588 const hasBuildInFlight = this . #currentBuild !== undefined ;
553589 const startupBlockedBeforeBuild = ! hasBuildInFlight &&
554590 this . #session === undefined && this . #startPromise !== undefined ;
@@ -557,8 +593,7 @@ export class DevCoordinator {
557593 void this . #releaseWatcher( ) . catch ( ( ) => undefined ) ;
558594 void this . #releaseLock( ) . catch ( ( ) => undefined ) ;
559595 }
560- const inFlight = this . #currentBuild ?? this . #startPromise;
561- const buildResult = await Promise . allSettled ( [ inFlight ?? Promise . resolve ( ) ] ) ;
596+ const inFlight : Promise < unknown > = this . #currentBuild ?? this . #startPromise ?? Promise . resolve ( ) ;
562597 const resources : readonly Readonly < {
563598 readonly close : ( ) => Promise < unknown > ;
564599 readonly resource : DevCoordinatorCloseFailure [ 'resource' ] ;
@@ -567,19 +602,23 @@ export class DevCoordinator {
567602 { close : ( ) => this . #diagnosticService. close ( ) , resource : 'diagnostics' } ,
568603 { close : ( ) => this . #releaseLock( ) , resource : 'lock' } ,
569604 ] ;
570- const results = await Promise . allSettled ( resources . map ( async ( { close } ) => close ( ) ) ) ;
571- const failures = [
572- ...buildResult . flatMap ( ( result ) : readonly DevCoordinatorCloseFailure [ ] =>
573- hasBuildInFlight && result . status === 'rejected'
574- ? [ Object . freeze ( { error : result . reason , resource : 'build' } ) ]
575- : [ ] ,
576- ) ,
577- ...results . flatMap ( ( result , index ) : readonly DevCoordinatorCloseFailure [ ] =>
578- result . status === 'rejected'
579- ? [ Object . freeze ( { error : result . reason , resource : resources [ index ] ! . resource } ) ]
580- : [ ] ,
581- ) ,
582- ] ;
583- if ( failures . length > 0 ) throw new DevCoordinatorCloseError ( failures ) ;
605+ const closeFailure = (
606+ exit : Exit . Exit < unknown , unknown > ,
607+ resource : DevCoordinatorCloseFailure [ 'resource' ] ,
608+ ) : readonly DevCoordinatorCloseFailure [ ] =>
609+ Exit . isFailure ( exit ) ? [ Object . freeze ( { error : Cause . squash ( exit . cause ) , resource } ) ] : [ ] ;
610+ return runPromise ( Effect . gen ( function * ( ) {
611+ const buildExit = yield * Effect . exit ( liftPromise ( ( ) => inFlight ) ) ;
612+ const releases = yield * Effect . forEach (
613+ resources ,
614+ ( { close } ) => Effect . exit ( liftPromise ( close ) ) ,
615+ { concurrency : 'unbounded' } ,
616+ ) ;
617+ const failures = [
618+ ...( hasBuildInFlight ? closeFailure ( buildExit , 'build' ) : [ ] ) ,
619+ ...releases . flatMap ( ( exit , index ) => closeFailure ( exit , resources [ index ] ! . resource ) ) ,
620+ ] ;
621+ if ( failures . length > 0 ) return yield * Effect . fail ( new DevCoordinatorCloseError ( failures ) ) ;
622+ } ) ) ;
584623 }
585624}
0 commit comments