2525 * ## What each `it` reads
2626 *
2727 * One fixture, two orders of events, each probed through the data route a user
28- * and an admin use: the set by name, the user grant of it by set id, and — after
29- * the restart — the package's object. The grant is made through the data door
30- * before the uninstall, so "no binding" is read off a row that existed, not off
31- * an empty table.
28+ * and an admin use: the set by name, the user grant of it by set id, and the
29+ * package's object — right after the DELETE and after the restart. The grant is
30+ * made through the data door before the uninstall, so "no binding" is read off a
31+ * row that existed, not off an empty table.
3232 *
3333 * A third order of events measures the re-seed window — DELETE, then a hot
34- * install of ANOTHER package, then restart — and records, as `it.fails`, the
35- * defect it found there; the block above that `describe` says what it is.
34+ * install of ANOTHER package, then restart. #21576: the DELETE now withdraws the
35+ * package from the running kernel (`SchemaRegistry.uninstallPackage`, the verb
36+ * the protocol's own uninstall uses), so nothing re-projects its set there; the
37+ * block above that `describe` says what used to happen.
38+ *
39+ * A fourth order is the withdrawal's control: a hot reinstall of the SAME
40+ * package in the same process, right after its DELETE, registers it again —
41+ * whatever the withdrawal took, the install path puts back.
3642 *
3743 * ## Spawn shape
3844 *
@@ -261,6 +267,10 @@ interface Run {
261267 uninstall ?: Answer ;
262268 /** Same process, right after the DELETE answered. */
263269 after ?: Grants ;
270+ /** The package's object right before the DELETE — it answered, so a later 404 is the DELETE's. */
271+ objectBefore ?: Answer ;
272+ /** #21576: the package's object in the same process, right after the DELETE answered. */
273+ objectAfter ?: Answer ;
264274 /** A restart on the same home. */
265275 restarted ?: Grants ;
266276 /** The package's object after the restart — the uninstall's own effect, as the control. */
@@ -269,6 +279,10 @@ interface Run {
269279 otherInstall ?: { exit : number | null ; output : string } ;
270280 /** Re-seed order only: same process, right after that second install. */
271281 afterOtherInstall ?: Grants ;
282+ /** Reinstall order only: the same package installed again, in the same process, after its DELETE. */
283+ reinstall ?: { exit : number | null ; output : string } ;
284+ /** Reinstall order only: the object and the set right after that reinstall. */
285+ afterReinstall ?: { object : Answer ; sets : Answer } ;
272286}
273287
274288/**
@@ -288,8 +302,10 @@ async function grantThenUninstall(live: LiveStart, session: Session, run: Run):
288302 permission_set_id : setId ,
289303 } ) ;
290304 run . before = await readGrants ( live , session . token , setId ) ;
305+ run . objectBefore = await http ( live , 'GET' , `/api/v1/data/${ TASK } ` , session . token ) ;
291306 run . uninstall = await http ( live , 'DELETE' , UNINSTALL , session . token ) ;
292307 run . after = await readGrants ( live , session . token , setId ) ;
308+ run . objectAfter = await http ( live , 'GET' , `/api/v1/data/${ TASK } ` , session . token ) ;
293309}
294310
295311async function readAfterRestart ( live : LiveStart , run : Run ) : Promise < void > {
@@ -298,7 +314,7 @@ async function readAfterRestart(live: LiveStart, run: Run): Promise<void> {
298314 run . object = await http ( live , 'GET' , `/api/v1/data/${ TASK } ` , token ) ;
299315}
300316
301- const runs : Record < 'hot' | 'restarted' | 'reseed' , Run > = { hot : { } , restarted : { } , reseed : { } } ;
317+ const runs : Record < 'hot' | 'restarted' | 'reseed' | 'reinstall' , Run > = { hot : { } , restarted : { } , reseed : { } , reinstall : { } } ;
302318
303319beforeAll ( async ( ) => {
304320 const root = mkdtempSync ( join ( tmpdir ( ) , 'install-local-uninstall-' ) ) ;
@@ -342,8 +358,9 @@ beforeAll(async () => {
342358 await stopGroup ( e . child ) ;
343359
344360 // ── order 3: hot install → DELETE → hot install of ANOTHER package → restart ──
345- // The DELETE does not withdraw the package from the running kernel, so it is
346- // still registered when the second install announces `metadata:reloaded`.
361+ // The second install announces `metadata:reloaded` into the process the
362+ // DELETE ran in, so whatever that process still counts as registered is
363+ // re-seeded — the withdrawal is what keeps the uninstalled package out of it.
347364 const reseedDir = join ( root , 'reseed' ) ;
348365 mkdirSync ( reseedDir , { recursive : true } ) ;
349366 const reseedHome = join ( reseedDir , 'home' ) ;
@@ -357,7 +374,23 @@ beforeAll(async () => {
357374 const g = await bootStart ( reseedDir , reseedHome , port ) ;
358375 await readAfterRestart ( g , runs . reseed ) ;
359376 await stopGroup ( g . child ) ;
360- } , 8 * BOOT_TIMEOUT_MS ) ;
377+
378+ // ── order 4: hot install → DELETE → hot reinstall of the SAME package ──────
379+ // The withdrawal's control: whatever `uninstallPackage` took from the running
380+ // kernel, the install path registers again, in the same process.
381+ const reinstallDir = join ( root , 'reinstall' ) ;
382+ mkdirSync ( reinstallDir , { recursive : true } ) ;
383+ const h = await bootStart ( reinstallDir , join ( reinstallDir , 'home' ) , port ) ;
384+ const hSession = await authenticate ( h ) ;
385+ runs . reinstall . install = await packageInstall ( appDir , h ) ;
386+ await grantThenUninstall ( h , hSession , runs . reinstall ) ;
387+ runs . reinstall . reinstall = await packageInstall ( appDir , h ) ;
388+ runs . reinstall . afterReinstall = {
389+ object : await http ( h , 'GET' , `/api/v1/data/${ TASK } ` , hSession . token ) ,
390+ sets : await http ( h , 'GET' , `/api/v1/data/sys_permission_set?name=${ PERMISSION_SET } ` , hSession . token ) ,
391+ } ;
392+ await stopGroup ( h . child ) ;
393+ } , 9 * BOOT_TIMEOUT_MS ) ;
361394
362395afterAll ( async ( ) => {
363396 for ( const child of groups ) await stopGroup ( child ) ;
@@ -392,6 +425,16 @@ describe('#21490: an install-local uninstall runs the registered uninstall clean
392425 expect ( rowsOf ( run . after ! . bindings ) , JSON . stringify ( run . after ! . bindings . body ) ) . toEqual ( [ ] ) ;
393426 } ) ;
394427
428+ // #21576: the DELETE withdraws the package from the running kernel, so
429+ // its object answers at once what it used to answer only after a
430+ // restart — the same status and the same code.
431+ it ( 'right after the DELETE: the package object answers what it answers after a restart — no restart needed' , ( ) => {
432+ const run = runs [ name ] ;
433+ expect ( run . objectBefore ?. status , JSON . stringify ( run . objectBefore ?. body ) ) . toBe ( 200 ) ;
434+ expect ( run . objectAfter ?. status , JSON . stringify ( run . objectAfter ?. body ) ) . toBe ( 404 ) ;
435+ expect ( run . objectAfter ?. body ?. error ?. code , JSON . stringify ( run . objectAfter ?. body ) ) . toBe ( run . object ?. body ?. error ?. code ) ;
436+ } ) ;
437+
395438 it ( 'after a restart: still no set and no grant, and the package object is gone' , ( ) => {
396439 const run = runs [ name ] ;
397440 expect ( run . object ?. status , JSON . stringify ( run . object ?. body ) ) . toBe ( 404 ) ;
@@ -403,19 +446,19 @@ describe('#21490: an install-local uninstall runs the registered uninstall clean
403446 } ) ;
404447 }
405448
406- // ── The re-seed window: MEASURED RED, reported for filing, not fixed here ──
449+ // ── The re-seed window (#21576) ─────────────────────────────────────── ──
407450 //
408- // This DELETE leaves the package registered in the running kernel until the
409- // next restart ( the response's own note says so) , and plugin-security's
410- // `metadata:reloaded` subscriber re-runs the declared-permission seeding over
411- // every package the kernel holds. So another package's hot install before
412- // that restart re-projects the uninstalled package's set as a fresh
413- // `managed_by: package` row, and the restart leaves it orphaned: the package
414- // is gone, its set is not. The grant does NOT come back — the cleanup deleted
415- // the binding and the seeding writes none — and that half is pinned plainly .
451+ // This DELETE used to leave the package registered in the running kernel
452+ // until the next restart , and plugin-security's `metadata:reloaded`
453+ // subscriber re-runs the declared-permission seeding over every package the
454+ // kernel holds. So another package's hot install before that restart
455+ // re-projected the uninstalled package's set as a fresh `managed_by: package`
456+ // row, and the restart left it orphaned: the package gone, its set not. The
457+ // grant never came back — the cleanup deleted the binding and the seeding
458+ // writes none.
416459 //
417- // The two set readings are `it.fails`: each turns red the day its half is
418- // fixed, which is the cue to promote it to a plain assertion .
460+ // The DELETE now withdraws the package from the running kernel, so no reader
461+ // of the registered packages — that seeding included — counts it again .
419462 describe ( 'hot install → DELETE → hot install of another package → restart (the re-seed window)' , ( ) => {
420463 it ( 'precondition: both installs landed, and the DELETE revoked the set and its grant' , ( ) => {
421464 const run = runs . reseed ;
@@ -439,14 +482,40 @@ describe('#21490: an install-local uninstall runs the registered uninstall clean
439482 expect ( run . object ?. status , JSON . stringify ( run . object ?. body ) ) . toBe ( 404 ) ;
440483 } ) ;
441484
442- it . fails ( 'KNOWN-BROKEN: the other package\'s hot install re-projects the uninstalled package\'s set (promote to a plain assertion once fixed) ', ( ) => {
485+ it ( ' the other package\'s hot install does not re-project the uninstalled package\'s set', ( ) => {
443486 const run = runs . reseed ;
444487 expect ( rowsOf ( run . afterOtherInstall ! . sets ) , JSON . stringify ( run . afterOtherInstall ! . sets . body ) ) . toEqual ( [ ] ) ;
445488 } ) ;
446489
447- it . fails ( 'KNOWN-BROKEN: that re-projected set survives the restart as an orphan row (promote to a plain assertion once fixed) ', ( ) => {
490+ it ( 'after the restart there is no package-managed set for the uninstalled package — no orphan row ', ( ) => {
448491 const run = runs . reseed ;
449492 expect ( rowsOf ( run . restarted ! . sets ) , JSON . stringify ( run . restarted ! . sets . body ) ) . toEqual ( [ ] ) ;
450493 } ) ;
451494 } ) ;
495+
496+ // ── The withdrawal's control (#21576) ───────────────────────────────────
497+ //
498+ // `uninstallPackage` takes the package's objects, namespace, metadata items
499+ // and record out of the running kernel. A hot reinstall of the same package
500+ // in the same process puts every one of them back: the object answers again
501+ // and the set is projected again — once, as the package's own.
502+ describe ( 'hot install → DELETE → hot reinstall of the same package (the withdrawal\'s control)' , ( ) => {
503+ it ( 'precondition: the install landed, and the DELETE took the object out of the running kernel' , ( ) => {
504+ const run = runs . reinstall ;
505+ expect ( run . install ?. exit , run . install ?. output ) . toBe ( 0 ) ;
506+ expect ( run . uninstall ?. status , JSON . stringify ( run . uninstall ?. body ) ) . toBe ( 200 ) ;
507+ expect ( run . objectBefore ?. status , JSON . stringify ( run . objectBefore ?. body ) ) . toBe ( 200 ) ;
508+ expect ( run . objectAfter ?. status , JSON . stringify ( run . objectAfter ?. body ) ) . toBe ( 404 ) ;
509+ expect ( rowsOf ( run . after ! . sets ) , JSON . stringify ( run . after ! . sets . body ) ) . toEqual ( [ ] ) ;
510+ } ) ;
511+
512+ it ( 'the reinstall registers the package again: its object answers, and its set is projected once' , ( ) => {
513+ const run = runs . reinstall ;
514+ expect ( run . reinstall ?. exit , run . reinstall ?. output ) . toBe ( 0 ) ;
515+ expect ( run . afterReinstall ! . object . status , JSON . stringify ( run . afterReinstall ! . object . body ) ) . toBe ( 200 ) ;
516+ expect ( run . afterReinstall ! . sets . status ) . toBe ( 200 ) ;
517+ expect ( rowsOf ( run . afterReinstall ! . sets ) . map ( ( r ) => [ r ?. name , r ?. managed_by , r ?. package_id ] ) , JSON . stringify ( run . afterReinstall ! . sets . body ) )
518+ . toEqual ( [ [ PERMISSION_SET , 'package' , APP_ID ] ] ) ;
519+ } ) ;
520+ } ) ;
452521} ) ;
0 commit comments