@@ -75,6 +75,21 @@ const BODY_APP_ID = 'com.example.jobsapp';
7575const BODY_TICK = 'jobs_app_tick' ;
7676const BODY_JOB = 'jobs_app_tick_body' ;
7777
78+ /** Another installed package — the control a package's uninstall or reinstall must leave running. */
79+ const OTHER_APP_ID = 'com.example.otherjobs' ;
80+ const OTHER_TICK = 'other_jobs_tick' ;
81+ const OTHER_JOB = 'other_jobs_tick_body' ;
82+
83+ /** A package reinstalled with a version that DROPS one of its two jobs. */
84+ const DROP_APP_ID = 'com.example.dropjobs' ;
85+ const DROP_TICK = 'drop_jobs_tick' ;
86+ const DROP_KEPT = 'drop_jobs_kept' ;
87+ const DROP_GONE = 'drop_jobs_gone' ;
88+
89+ /** "Writes no further row": let an in-flight run land, take the floor, then read again this much later. */
90+ const SETTLE_MS = 1_500 ;
91+ const QUIET_WAIT_MS = 4_000 ;
92+
7893const HANDLER_APP_ID = 'com.example.handlerjobs' ;
7994const HANDLER_TICK = 'handler_jobs_tick' ;
8095const HANDLER_JOB = 'handler_jobs_tick_handler' ;
@@ -115,6 +130,21 @@ const BODY_ARTIFACT = {
115130 jobs : [ bodyJob ( BODY_JOB , BODY_TICK ) ] ,
116131} ;
117132
133+ const OTHER_ARTIFACT = {
134+ manifest : { id : OTHER_APP_ID , namespace : 'other_jobs' , version : '0.1.0' , type : 'app' , name : 'Other Jobs' } ,
135+ objects : [ tickObject ( OTHER_TICK ) ] ,
136+ jobs : [ bodyJob ( OTHER_JOB , OTHER_TICK ) ] ,
137+ } ;
138+
139+ /** One version of the drop package, declaring `jobs` (all body jobs into one object). */
140+ function dropArtifact ( version : string , jobs : string [ ] ) {
141+ return {
142+ manifest : { id : DROP_APP_ID , namespace : 'drop_jobs' , version, type : 'app' , name : 'Drop Jobs' } ,
143+ objects : [ tickObject ( DROP_TICK ) ] ,
144+ jobs : jobs . map ( ( name ) => bodyJob ( name , DROP_TICK ) ) ,
145+ } ;
146+ }
147+
118148/** The handler-only package: its one enabled job names a function no JSON door carries. */
119149const HANDLER_ARTIFACT = {
120150 manifest : { id : HANDLER_APP_ID , namespace : 'handler_jobs' , version : '0.1.0' , type : 'app' , name : 'Handler Jobs' } ,
@@ -304,13 +334,41 @@ async function awaitRuns(live: LiveStart, token: string, object: string, job: st
304334 return { answer, floor } ;
305335}
306336
337+ const sleep = ( ms : number ) => new Promise ( ( r ) => setTimeout ( r , ms ) ) ;
338+
339+ /**
340+ * The reading for an act that must STOP `job`: let a run already in flight
341+ * land, take the row count as the floor, wait, and count again. A job that
342+ * was stopped leaves `after === floor`; one still scheduled (every second)
343+ * adds rows in between.
344+ */
345+ async function quietAfter ( live : LiveStart , token : string , object : string , job : string ) : Promise < { floor : number ; after : number } > {
346+ await sleep ( SETTLE_MS ) ;
347+ const floor = rowsOf ( await jobRows ( live , token , object , job ) ) . length ;
348+ await sleep ( QUIET_WAIT_MS ) ;
349+ const after = rowsOf ( await jobRows ( live , token , object , job ) ) . length ;
350+ return { floor, after } ;
351+ }
352+
307353interface InstallRun { exit : number | null ; output : string }
308354const readings : {
309355 bodyInstall ?: InstallRun ;
310356 handlerInstall ?: InstallRun ;
311357 installed ?: Answer ;
312358 afterInstall ?: { answer : Answer ; floor : number } ;
313359 afterRestart ?: { answer : Answer ; floor : number } ;
360+ otherInstall ?: InstallRun ;
361+ dropInstall ?: InstallRun ;
362+ dropReinstall ?: InstallRun ;
363+ dropBefore ?: { answer : Answer ; floor : number } ;
364+ dropGoneHot ?: { floor : number ; after : number } ;
365+ dropKeptHot ?: { answer : Answer ; floor : number } ;
366+ uninstall ?: Answer ;
367+ bodyGoneHot ?: { floor : number ; after : number } ;
368+ otherHot ?: { answer : Answer ; floor : number } ;
369+ bodyGoneRestart ?: { floor : number ; after : number } ;
370+ dropGoneRestart ?: { floor : number ; after : number } ;
371+ dropKeptRestart ?: { answer : Answer ; floor : number } ;
314372 controlBody ?: { answer : Answer ; floor : number } ;
315373 controlHandler ?: { answer : Answer ; floor : number } ;
316374 output : Record < string , string > ;
@@ -329,6 +387,12 @@ beforeAll(async () => {
329387 write ( bodyApp , BODY_ARTIFACT ) ;
330388 write ( handlerApp , HANDLER_ARTIFACT ) ;
331389 write ( controlApp , CONTROL_ARTIFACT ) ;
390+ const otherApp = join ( root , 'other-app' ) ;
391+ const dropAppV1 = join ( root , 'drop-app-v1' ) ;
392+ const dropAppV2 = join ( root , 'drop-app-v2' ) ;
393+ write ( otherApp , OTHER_ARTIFACT ) ;
394+ write ( dropAppV1 , dropArtifact ( '0.1.0' , [ DROP_KEPT , DROP_GONE ] ) ) ;
395+ write ( dropAppV2 , dropArtifact ( '0.2.0' , [ DROP_KEPT ] ) ) ;
332396 writeFileSync ( join ( controlApp , 'dist' , 'runtime.mjs' ) , CONTROL_RUNTIME_MODULE , 'utf8' ) ;
333397
334398 // The runtime boots the HOST artifact — never a package — so the packages
@@ -344,18 +408,40 @@ beforeAll(async () => {
344408 const first = await bootStart ( runtimeDir , home , port , [ '--artifact' , hostArtifact ] ) ;
345409 const token = await authenticate ( first ) ;
346410 readings . bodyInstall = await packageInstall ( bodyApp , first ) ;
411+ readings . otherInstall = await packageInstall ( otherApp , first ) ;
412+ readings . dropInstall = await packageInstall ( dropAppV1 , first ) ;
347413 readings . handlerInstall = await packageInstall ( handlerApp , first ) ;
348414 readings . installed = await http ( first , 'GET' , '/api/v1/marketplace/install-local' , token ) ;
349415 readings . afterInstall = await awaitRuns ( first , token , BODY_TICK , BODY_JOB , 0 ) ;
416+
417+ // Reinstall the drop package with a version that no longer declares DROP_GONE.
418+ readings . dropBefore = await awaitRuns ( first , token , DROP_TICK , DROP_GONE , 0 ) ;
419+ readings . dropReinstall = await packageInstall ( dropAppV2 , first ) ;
420+ readings . dropGoneHot = await quietAfter ( first , token , DROP_TICK , DROP_GONE ) ;
421+ readings . dropKeptHot = await awaitRuns ( first , token , DROP_TICK , DROP_KEPT ,
422+ rowsOf ( await jobRows ( first , token , DROP_TICK , DROP_KEPT ) ) . length ) ;
423+
424+ // Uninstall the body package; the other package is the control.
425+ readings . uninstall = await http ( first , 'DELETE' , `/api/v1/marketplace/install-local/${ BODY_APP_ID } ` , token ) ;
426+ readings . bodyGoneHot = await quietAfter ( first , token , BODY_TICK , BODY_JOB ) ;
427+ readings . otherHot = await awaitRuns ( first , token , OTHER_TICK , OTHER_JOB ,
428+ rowsOf ( await jobRows ( first , token , OTHER_TICK , OTHER_JOB ) ) . length ) ;
350429 readings . output . install = first . output ( ) ;
351430 await stopGroup ( first . child ) ;
352431
353432 // ── boot 2: same host, home and cwd — the ledger rehydrates on kernel:ready ──
354433 const second = await bootStart ( runtimeDir , home , port , [ '--artifact' , hostArtifact ] ) ;
355434 const token2 = await authenticate ( second ) ;
356- // The rows boot 1 left behind are the floor: only a run in THIS boot lifts it.
357- const floor = rowsOf ( await jobRows ( second , token2 , BODY_TICK , BODY_JOB ) ) . length ;
358- readings . afterRestart = await awaitRuns ( second , token2 , BODY_TICK , BODY_JOB , floor ) ;
435+ // The rows boot 1 left behind are the floors: only a run in THIS boot lifts one.
436+ const floorOf = async ( object : string , job : string ) => rowsOf ( await jobRows ( second , token2 , object , job ) ) . length ;
437+ const bodyFloor = await floorOf ( BODY_TICK , BODY_JOB ) ;
438+ const goneFloor = await floorOf ( DROP_TICK , DROP_GONE ) ;
439+ const restartedAt = Date . now ( ) ;
440+ readings . afterRestart = await awaitRuns ( second , token2 , OTHER_TICK , OTHER_JOB , await floorOf ( OTHER_TICK , OTHER_JOB ) ) ;
441+ readings . dropKeptRestart = await awaitRuns ( second , token2 , DROP_TICK , DROP_KEPT , await floorOf ( DROP_TICK , DROP_KEPT ) ) ;
442+ await sleep ( Math . max ( 0 , QUIET_WAIT_MS - ( Date . now ( ) - restartedAt ) ) ) ;
443+ readings . bodyGoneRestart = { floor : bodyFloor , after : await floorOf ( BODY_TICK , BODY_JOB ) } ;
444+ readings . dropGoneRestart = { floor : goneFloor , after : await floorOf ( DROP_TICK , DROP_GONE ) } ;
359445 readings . output . restart = second . output ( ) ;
360446 await stopGroup ( second . child ) ;
361447
@@ -367,7 +453,7 @@ beforeAll(async () => {
367453 readings . controlBody = await awaitRuns ( third , token3 , BODY_TICK , BODY_JOB , 0 ) ;
368454 readings . output . control = third . output ( ) ;
369455 await stopGroup ( third . child ) ;
370- } , 3 * BOOT_TIMEOUT_MS + 6 * RUN_WAIT_MS ) ;
456+ } , 3 * BOOT_TIMEOUT_MS + 12 * RUN_WAIT_MS ) ;
371457
372458afterAll ( async ( ) => {
373459 for ( const child of groups ) await stopGroup ( child ) ;
@@ -389,12 +475,47 @@ describe('#21489: install-local runs job bodies and refuses handler-only jobs',
389475 expect ( rowsOf ( answer ) . length , `the installed body job never ran${ transcript ( 'install' ) } ` ) . toBeGreaterThan ( 0 ) ;
390476 } ) ;
391477
392- it ( 'after restart, the rehydrated body job runs again' , ( ) => {
478+ it ( "after restart, a rehydrated package's body job runs again" , ( ) => {
479+ expect ( readings . otherInstall ! . exit , readings . otherInstall ! . output ) . toBe ( 0 ) ;
393480 const { answer, floor } = readings . afterRestart ! ;
394481 expect ( answer . status , JSON . stringify ( answer . body ) ) . toBe ( 200 ) ;
395482 expect ( rowsOf ( answer ) . length , `no run after the restart${ transcript ( 'restart' ) } ` ) . toBeGreaterThan ( floor ) ;
396483 } ) ;
397484
485+ it ( "uninstall: the DELETE answers 200, and the uninstalled package's body job writes no further row — hot" , ( ) => {
486+ expect ( readings . uninstall ! . status , JSON . stringify ( readings . uninstall ! . body ) ) . toBe ( 200 ) ;
487+ const { floor, after } = readings . bodyGoneHot ! ;
488+ expect ( floor , 'precondition: the job had run before the uninstall' ) . toBeGreaterThan ( 0 ) ;
489+ expect ( after , `the uninstalled package's job kept running${ transcript ( 'install' ) } ` ) . toBe ( floor ) ;
490+ } ) ;
491+
492+ it ( '… and none after a restart' , ( ) => {
493+ const { floor, after } = readings . bodyGoneRestart ! ;
494+ expect ( after , `the uninstalled package's job ran after the restart${ transcript ( 'restart' ) } ` ) . toBe ( floor ) ;
495+ } ) ;
496+
497+ it ( "control: another package's job keeps running across that uninstall" , ( ) => {
498+ const { answer, floor } = readings . otherHot ! ;
499+ expect ( rowsOf ( answer ) . length , `the control package's job stopped${ transcript ( 'install' ) } ` ) . toBeGreaterThan ( floor ) ;
500+ } ) ;
501+
502+ it ( 'reinstall: a job the new version DROPPED writes no further row — hot, and none after a restart' , ( ) => {
503+ expect ( readings . dropInstall ! . exit , readings . dropInstall ! . output ) . toBe ( 0 ) ;
504+ expect ( readings . dropReinstall ! . exit , readings . dropReinstall ! . output ) . toBe ( 0 ) ;
505+ expect ( rowsOf ( readings . dropBefore ! . answer ) . length , 'precondition: the dropped job had run' ) . toBeGreaterThan ( 0 ) ;
506+ const hot = readings . dropGoneHot ! ;
507+ expect ( hot . after , `the dropped job kept running after the reinstall${ transcript ( 'install' ) } ` ) . toBe ( hot . floor ) ;
508+ const restart = readings . dropGoneRestart ! ;
509+ expect ( restart . after , `the dropped job ran after the restart${ transcript ( 'restart' ) } ` ) . toBe ( restart . floor ) ;
510+ } ) ;
511+
512+ it ( '… while the job the new version KEPT keeps running, hot and after a restart' , ( ) => {
513+ const hot = readings . dropKeptHot ! ;
514+ expect ( rowsOf ( hot . answer ) . length , `the kept job stopped${ transcript ( 'install' ) } ` ) . toBeGreaterThan ( hot . floor ) ;
515+ const restart = readings . dropKeptRestart ! ;
516+ expect ( rowsOf ( restart . answer ) . length , `the kept job did not run after the restart${ transcript ( 'restart' ) } ` ) . toBeGreaterThan ( restart . floor ) ;
517+ } ) ;
518+
398519 it ( 'the handler-only package is REFUSED, with its code and remedy, and nothing of it is installed' , ( ) => {
399520 const run = readings . handlerInstall ! ;
400521 expect ( run . exit , run . output ) . toBe ( 1 ) ;
0 commit comments