INT 21h AH=4Ah changes the size of a block without moving it. Shrinking always works, and the paragraphs given up become a free block. Growing works only if the memory right after the block is free; otherwise the call fails with error 8 and BX = the largest size the block could have.

Its main use is on the program's own block, which starts with the PSP. At start ES holds the PSP segment in a .COM and an .EXE alike, so ES is already right for the call.

How much to keep

A .COM needs its whole 64 KB unless it moves its stack: SP starts at FFFEh, the top of the segment, and shrinking below 1000h paragraphs leaves the stack in free memory, where the next allocation or a child program overwrites it. To keep less, move SP inside the program first, then compute the size at run time, in bytes from the PSP and rounded up to paragraphs:

MOV SP, OFFSET STACKTOP ; a stack inside the program
MOV BX, OFFSET STACKTOP + 15
MOV CL, 4
SHR BX, CL ; bytes from the PSP -> paragraphs
MOV AH, 4Ah
INT 21h
...
STACK1 DW 128 DUP(?) ; 256 bytes of stack
STACKTOP LABEL WORD

DOSBox-X's own programs on Z: do exactly this: each one starts with MOV SP, 400h and shrinks its block to 40h paragraphs, 1 KB.

prints: shrink to 64 KB: ok / our block: 1000h paragraphs / grow to FFFFh paragraphs: error 8 / grow to all there is: ok
MOV DX, OFFSET SHRINKM1
MOV AH, 09h
INT 21h
MOV AH, 4Ah ; DOS: resize the block at ES (our PSP)
MOV BX, 1000h ; 64 KB
INT 21h
CALL RESULT ; ok
MOV DX, OFFSET OURSM1
MOV AH, 09h
INT 21h
MOV AX, CS ; our MCB: the paragraph below the PSP
DEC AX
MOV ES, AX
XOR DI, DI
MOV AX, ES:[DI+3] ; its size field
CALL PHEX ; 1000
MOV DX, OFFSET PARASM1
MOV AH, 09h
INT 21h
PUSH CS
POP ES ; ES = our PSP again
MOV DX, OFFSET GROWM1
MOV AH, 09h
INT 21h
MOV AH, 4Ah
MOV BX, 0FFFFh ; more than there can be
INT 21h
MOV MAX1, BX ; BX = the most we can have
CALL RESULT ; error 8
MOV DX, OFFSET ALLM1
MOV AH, 09h
INT 21h
MOV AH, 4Ah
MOV BX, MAX1
INT 21h
CALL RESULT ; ok
SHRINKM1 DB "shrink to 64 KB: $"
OURSM1 DB "our block: $"
PARASM1 DB "h paragraphs", 13, 10, "$"
GROWM1 DB "grow to FFFFh paragraphs: $"
ALLM1 DB "grow to all there is: $"
MAX1 DW 0
RESULT PROC NEAR ; print "ok", or "error" and AX when CF = 1
PUSHF ; keeps AX and CF for the caller
PUSH AX
MOV DX, OFFSET OKMSG
JNC RESULT1
MOV DX, OFFSET ERRMSG
MOV AH, 09h
INT 21h
POP AX
PUSH AX
CALL PDEC ; the error code
MOV DX, OFFSET CRLF
RESULT1:
MOV AH, 09h
INT 21h
POP AX
POPF
RET
RESULT ENDP
OKMSG DB "ok", 13, 10, "$"
ERRMSG DB "error $"
PDEC PROC NEAR ; print AX in decimal (changes AX BX CX DX)
MOV BX, 10
XOR CX, CX ; digits on the stack
PDEC1:
XOR DX, DX
DIV BX ; AX = AX / 10, DX = the last digit
PUSH DX
INC CX
OR AX, AX
JNZ PDEC1
PDEC2:
POP DX ; the digits come back first to last
ADD DL, "0"
MOV AH, 02h
INT 21h
LOOP PDEC2
RET
PDEC ENDP
PHEX PROC NEAR ; print AX as four hex digits (changes AX BX CX DX)
MOV BX, AX
MOV CX, 4
PHEX1:
PUSH CX
MOV CL, 4
ROL BX, CL ; the top digit to the bottom
POP CX
MOV DL, BL
AND DL, 0Fh
ADD DL, "0"
CMP DL, "9"
JBE PHEX2
ADD DL, 7 ; "A" to "F"
PHEX2:
MOV AH, 02h
INT 21h
LOOP PHEX1
RET
PHEX ENDP
CRLF DB 13, 10, "$"

Errors

Error 8: not enough memory to grow, BX = the largest size possible. Error 7: ES - 1 is not an MCB (DOSBox-X reports 7 here, where 49h reports 9 for the same mistake), or the chain is broken. DOSBox-X already leaves the block at the largest size when growing fails; DOS versions differ in this, so call again with the BX that came back, as the example does.