The behavior is correct but not intuitive. Only isolation levels SERIALIZABLE, SNAPSHOT, or READ_COMMITTED with the READ_COMMITTED_SNAPSHOT ON database option guarantee the count will match the number of rows inserted by the concurrent transaction after the transaction is committed.
READ COMMITTED and REPEATABLE READ count only committed rows but the devil is in the details. During a scan, a row will be counted only if it was inserted and committed before the scan point is reached. What happens here is the SELECT query reads empty pages during the IAM scan (counting zero rows in the then empty pages) until it gets blocked by an exclusive lock on an uncommitted inserted row. The INSERT session later fills those already read pages with rows and commits, releasing locks and unblocking the SELECT query. The SELECT query proceeds to count committed rows in the remaining pages. However, the rows in the already read (previously empty) pages are not counted.
Here's a modified version of your script can reproduce the issue in a single iteration (at least on my machine) by using a larger transaction.
--session 1
USE tempdb;
DROP DATABASE IF EXISTS Repro;
CREATE DATABASE Repro;
ALTER DATABASE Repro SET RECOVERY SIMPLE;
GO
USE Repro;
CREATE TABLE dbo.T1 (
id int
);
GO
SET NOCOUNT ON;
DECLARE @x AS int = 0;
BEGIN TRAN;
WHILE @x < 1000000
BEGIN
INSERT INTO dbo.T1 (id) VALUES(@x);
SET @x += 1;
END;
COMMIT;
GO
--session 2
USE Repro;
SET NOCOUNT ON;
SET TRANSACTION ISOLATION LEVEL READ COMMITTED;
DECLARE @RowCount int, @FirstRowReadId int, @LastRowReadId int;
SELECT
@RowCount = COUNT(*)
, @FirstRowReadId = MIN(id)
, @LastRowReadId = MAX(id)
FROM dbo.T1;
IF @RowCount NOT IN (0,1000000)
BEGIN
RAISERROR('Unexpected row count %d', 16, 1, @RowCount);
END;
SELECT
COUNT(*) AS RowCountInserted
, @RowCount AS RowCountRead
, MIN(id) AS FirstInsertId
, @FirstRowReadId AS FirstRowReadId
, MAX(id) AS LastInsertId
, @LastRowReadId AS LastRowReadId
FROM dbo.T1;
USE tempdb;
GO