Files
seaweedfs/weed/storage/blockvol/iscsi/session_test.go
T
Ping QiuandClaude Opus 4.6 c39080ceaa feat: Phase 4A CP4b-4 -- HA integration tests, admin HTTP, 5 bug fixes
Add HTTP admin server to iscsi-target binary (POST /assign, GET /status,
POST /replica, POST /rebuild) and 7 HA integration tests validating
failover, split-brain prevention, epoch fencing, and demote-under-IO.

New files:
- admin.go: HTTP admin endpoint with input validation
- ha_target.go: HATarget helper wrapping Target + admin HTTP calls
- ha_test.go: 7 HA tests (all PASS on WSL2, 67.7s total)

Bug fixes:
- BUG-CP4B4-1: CmdSN init (expCmdSN=0 not 1, first SCSI cmd was dropped)
- BUG-CP4B4-2: RoleNone->RoleReplica missing SetEpoch (WAL rejected)
- BUG-CP4B4-3: replica applyEntry didn't update vol.nextLSN (status=0)
- BUG-CP4B4-4: PID discovery killed primary instead of replica (shared
  binPath; fixed by grepping volFile)
- BUG-CP4B4-5: artifact collector overwrote primary log with replica log
  (added CollectLabeled method)

Also: 3s write deadline on WAL shipper data connection to avoid 120s TCP
retransmission timeout when replica is dead.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 15:04:59 -08:00

1140 lines
28 KiB
Go

package iscsi
import (
"bytes"
"encoding/binary"
"io"
"log"
"net"
"testing"
"time"
)
// testResolver implements TargetResolver, TargetLister, and DeviceLookup.
type testResolver struct {
targets []DiscoveryTarget
dev BlockDevice
}
func (r *testResolver) HasTarget(name string) bool {
for _, t := range r.targets {
if t.Name == name {
return true
}
}
return false
}
func (r *testResolver) ListTargets() []DiscoveryTarget {
return r.targets
}
func (r *testResolver) LookupDevice(iqn string) BlockDevice {
if r.dev != nil {
return r.dev
}
return &nullDevice{}
}
func newTestResolver() *testResolver {
return newTestResolverWithDevice(nil)
}
func newTestResolverWithDevice(dev BlockDevice) *testResolver {
return &testResolver{
targets: []DiscoveryTarget{
{Name: testTargetName, Address: "127.0.0.1:3260,1"},
},
dev: dev,
}
}
func TestSession(t *testing.T) {
tests := []struct {
name string
run func(t *testing.T)
}{
{"login_and_read", testLoginAndRead},
{"login_and_write", testLoginAndWrite},
{"nop_ping", testNOPPing},
{"logout", testLogout},
{"discovery_session", testDiscoverySession},
{"task_mgmt", testTaskMgmt},
{"reject_scsi_before_login", testRejectSCSIBeforeLogin},
{"connection_close", testConnectionClose},
// Phase 3 CP2: RX/TX split tests.
{"rxtx_read_write_basic", testRXTXReadWriteBasic},
{"rxtx_pipelined_reads", testRXTXPipelinedReads},
{"rxtx_pipelined_writes", testRXTXPipelinedWrites},
{"rxtx_write_with_r2t", testRXTXWriteWithR2T},
{"rxtx_mixed_ops", testRXTXMixedOps},
{"rxtx_large_read_datain", testRXTXLargeReadDataIn},
{"rxtx_statsn_monotonic", testRXTXStatSNMonotonic},
{"rxtx_statsn_datain_no_increment", testRXTXStatSNDataInNoIncrement},
{"rxtx_statsn_mixed_types", testRXTXStatSNMixedTypes},
{"rxtx_shutdown_clean", testRXTXShutdownClean},
{"rxtx_conn_drop_reader", testRXTXConnDropReader},
{"rxtx_conn_drop_writer", testRXTXConnDropWriter},
// Phase 3 code review fixes.
{"rxtx_r2t_statsn_fresh", testRXTXR2TStatSNFresh},
{"rxtx_tx_error_exits_clean", testRXTXTxErrorExitsClean},
{"rxtx_login_phase_reject", testRXTXLoginPhaseReject},
// Phase 3 bug fixes (P3-BUG-2, P3-BUG-3).
{"rxtx_pending_queue_overflow", testRXTXPendingQueueOverflow},
{"rxtx_dataout_timeout", testRXTXDataOutTimeout},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tt.run(t)
})
}
}
type sessionTestEnv struct {
clientConn net.Conn
session *Session
done chan error
}
func setupSession(t *testing.T) *sessionTestEnv {
return setupSessionWithConfig(t, nil)
}
func setupSessionWithConfig(t *testing.T, cfgFn func(*TargetConfig)) *sessionTestEnv {
t.Helper()
client, server := net.Pipe()
dev := newMockDevice(1024 * 4096) // 1024 blocks
config := DefaultTargetConfig()
config.TargetName = testTargetName
if cfgFn != nil {
cfgFn(&config)
}
resolver := newTestResolverWithDevice(dev)
logger := log.New(io.Discard, "", 0)
sess := NewSession(server, config, resolver, resolver, logger)
done := make(chan error, 1)
go func() {
done <- sess.HandleConnection()
}()
t.Cleanup(func() {
sess.Close()
client.Close()
})
return &sessionTestEnv{clientConn: client, session: sess, done: done}
}
func doLogin(t *testing.T, conn net.Conn) {
t.Helper()
params := NewParams()
params.Set("InitiatorName", testInitiatorName)
params.Set("TargetName", testTargetName)
params.Set("SessionType", "Normal")
req := makeLoginReq(StageSecurityNeg, StageFullFeature, true, params)
req.SetCmdSN(1)
if err := WritePDU(conn, req); err != nil {
t.Fatal(err)
}
resp, err := ReadPDU(conn)
if err != nil {
t.Fatal(err)
}
if resp.Opcode() != OpLoginResp {
t.Fatalf("expected LoginResp, got %s", OpcodeName(resp.Opcode()))
}
if resp.LoginStatusClass() != LoginStatusSuccess {
t.Fatalf("login failed: %d/%d", resp.LoginStatusClass(), resp.LoginStatusDetail())
}
}
func testLoginAndRead(t *testing.T) {
env := setupSession(t)
doLogin(t, env.clientConn)
// Send SCSI READ_10 for 1 block at LBA 0
cmd := &PDU{}
cmd.SetOpcode(OpSCSICmd)
cmd.SetOpSpecific1(FlagF | FlagR) // Final + Read
cmd.SetInitiatorTaskTag(1)
cmd.SetExpectedDataTransferLength(4096)
cmd.SetCmdSN(0)
cmd.SetExpStatSN(2)
var cdb [16]byte
cdb[0] = ScsiRead10
binary.BigEndian.PutUint32(cdb[2:6], 0) // LBA=0
binary.BigEndian.PutUint16(cdb[7:9], 1) // 1 block
cmd.SetCDB(cdb)
if err := WritePDU(env.clientConn, cmd); err != nil {
t.Fatal(err)
}
// Expect Data-In with S-bit (single PDU, 4096 bytes of zeros)
resp, err := ReadPDU(env.clientConn)
if err != nil {
t.Fatal(err)
}
if resp.Opcode() != OpSCSIDataIn {
t.Fatalf("expected Data-In, got %s", OpcodeName(resp.Opcode()))
}
if resp.OpSpecific1()&FlagS == 0 {
t.Fatal("S-bit not set")
}
if len(resp.DataSegment) != 4096 {
t.Fatalf("data length: %d", len(resp.DataSegment))
}
}
func testLoginAndWrite(t *testing.T) {
env := setupSession(t)
doLogin(t, env.clientConn)
// SCSI WRITE_10: 1 block at LBA 5 with immediate data
cmd := &PDU{}
cmd.SetOpcode(OpSCSICmd)
cmd.SetOpSpecific1(FlagF | FlagW) // Final + Write
cmd.SetInitiatorTaskTag(1)
cmd.SetExpectedDataTransferLength(4096)
cmd.SetCmdSN(0)
var cdb [16]byte
cdb[0] = ScsiWrite10
binary.BigEndian.PutUint32(cdb[2:6], 5)
binary.BigEndian.PutUint16(cdb[7:9], 1)
cmd.SetCDB(cdb)
// Include immediate data
cmd.DataSegment = bytes.Repeat([]byte{0xAA}, 4096)
if err := WritePDU(env.clientConn, cmd); err != nil {
t.Fatal(err)
}
// Expect SCSI Response (good)
resp, err := ReadPDU(env.clientConn)
if err != nil {
t.Fatal(err)
}
if resp.Opcode() != OpSCSIResp {
t.Fatalf("expected SCSI Response, got %s", OpcodeName(resp.Opcode()))
}
if resp.SCSIStatus() != SCSIStatusGood {
t.Fatalf("status: %d", resp.SCSIStatus())
}
}
func testNOPPing(t *testing.T) {
env := setupSession(t)
doLogin(t, env.clientConn)
// Send NOP-Out
nop := &PDU{}
nop.SetOpcode(OpNOPOut)
nop.SetOpSpecific1(FlagF)
nop.SetInitiatorTaskTag(0x9999)
nop.SetImmediate(true)
nop.DataSegment = []byte("ping")
if err := WritePDU(env.clientConn, nop); err != nil {
t.Fatal(err)
}
resp, err := ReadPDU(env.clientConn)
if err != nil {
t.Fatal(err)
}
if resp.Opcode() != OpNOPIn {
t.Fatalf("expected NOP-In, got %s", OpcodeName(resp.Opcode()))
}
if resp.InitiatorTaskTag() != 0x9999 {
t.Fatal("ITT mismatch")
}
if string(resp.DataSegment) != "ping" {
t.Fatalf("echo data: %q", resp.DataSegment)
}
}
func testLogout(t *testing.T) {
env := setupSession(t)
doLogin(t, env.clientConn)
// Send Logout
logout := &PDU{}
logout.SetOpcode(OpLogoutReq)
logout.SetOpSpecific1(FlagF)
logout.SetInitiatorTaskTag(0xAAAA)
logout.SetCmdSN(0)
if err := WritePDU(env.clientConn, logout); err != nil {
t.Fatal(err)
}
resp, err := ReadPDU(env.clientConn)
if err != nil {
t.Fatal(err)
}
if resp.Opcode() != OpLogoutResp {
t.Fatalf("expected Logout Response, got %s", OpcodeName(resp.Opcode()))
}
// After logout, the connection should be closed by the target.
// Verify by trying to read -- should get EOF.
_, err = ReadPDU(env.clientConn)
if err == nil {
t.Fatal("expected EOF after logout")
}
}
func testDiscoverySession(t *testing.T) {
env := setupSession(t)
// Login as discovery session
params := NewParams()
params.Set("InitiatorName", testInitiatorName)
params.Set("SessionType", "Discovery")
req := makeLoginReq(StageSecurityNeg, StageFullFeature, true, params)
req.SetCmdSN(1)
WritePDU(env.clientConn, req)
ReadPDU(env.clientConn) // login resp
// Send SendTargets=All
textParams := NewParams()
textParams.Set("SendTargets", "All")
textReq := makeTextReq(textParams)
textReq.SetCmdSN(0)
if err := WritePDU(env.clientConn, textReq); err != nil {
t.Fatal(err)
}
resp, err := ReadPDU(env.clientConn)
if err != nil {
t.Fatal(err)
}
if resp.Opcode() != OpTextResp {
t.Fatalf("expected Text Response, got %s", OpcodeName(resp.Opcode()))
}
body := string(resp.DataSegment)
if len(body) == 0 {
t.Fatal("empty discovery response")
}
}
func testTaskMgmt(t *testing.T) {
env := setupSession(t)
doLogin(t, env.clientConn)
tm := &PDU{}
tm.SetOpcode(OpSCSITaskMgmt)
tm.SetOpSpecific1(FlagF | 0x01) // ABORT TASK
tm.SetInitiatorTaskTag(0xBBBB)
tm.SetImmediate(true)
if err := WritePDU(env.clientConn, tm); err != nil {
t.Fatal(err)
}
resp, err := ReadPDU(env.clientConn)
if err != nil {
t.Fatal(err)
}
if resp.Opcode() != OpSCSITaskResp {
t.Fatalf("expected Task Mgmt Response, got %s", OpcodeName(resp.Opcode()))
}
}
func testRejectSCSIBeforeLogin(t *testing.T) {
env := setupSession(t)
// Send SCSI command without login
cmd := &PDU{}
cmd.SetOpcode(OpSCSICmd)
cmd.SetOpSpecific1(FlagF | FlagR)
cmd.SetInitiatorTaskTag(1)
if err := WritePDU(env.clientConn, cmd); err != nil {
t.Fatal(err)
}
resp, err := ReadPDU(env.clientConn)
if err != nil {
t.Fatal(err)
}
if resp.Opcode() != OpReject {
t.Fatalf("expected Reject, got %s", OpcodeName(resp.Opcode()))
}
}
func testConnectionClose(t *testing.T) {
env := setupSession(t)
doLogin(t, env.clientConn)
// Close client side
env.clientConn.Close()
select {
case err := <-env.done:
if err != nil {
t.Fatalf("unexpected error on clean close: %v", err)
}
case <-time.After(2 * time.Second):
t.Fatal("session did not detect close")
}
}
// --- Phase 3 CP2: RX/TX split tests ---
func sendSCSIRead(t *testing.T, conn net.Conn, lba uint32, blocks uint16, itt uint32, cmdSN uint32) {
t.Helper()
cmd := &PDU{}
cmd.SetOpcode(OpSCSICmd)
cmd.SetOpSpecific1(FlagF | FlagR)
cmd.SetInitiatorTaskTag(itt)
cmd.SetExpectedDataTransferLength(uint32(blocks) * 4096)
cmd.SetCmdSN(cmdSN)
var cdb [16]byte
cdb[0] = ScsiRead10
binary.BigEndian.PutUint32(cdb[2:6], lba)
binary.BigEndian.PutUint16(cdb[7:9], blocks)
cmd.SetCDB(cdb)
if err := WritePDU(conn, cmd); err != nil {
t.Fatalf("sendSCSIRead: %v", err)
}
}
func sendSCSIWriteImmediate(t *testing.T, conn net.Conn, lba uint32, data []byte, itt uint32, cmdSN uint32) {
t.Helper()
cmd := &PDU{}
cmd.SetOpcode(OpSCSICmd)
cmd.SetOpSpecific1(FlagF | FlagW)
cmd.SetInitiatorTaskTag(itt)
cmd.SetExpectedDataTransferLength(uint32(len(data)))
cmd.SetCmdSN(cmdSN)
var cdb [16]byte
cdb[0] = ScsiWrite10
binary.BigEndian.PutUint32(cdb[2:6], lba)
binary.BigEndian.PutUint16(cdb[7:9], uint16(len(data)/4096))
cmd.SetCDB(cdb)
cmd.DataSegment = data
if err := WritePDU(conn, cmd); err != nil {
t.Fatalf("sendSCSIWriteImmediate: %v", err)
}
}
func testRXTXReadWriteBasic(t *testing.T) {
env := setupSession(t)
doLogin(t, env.clientConn)
// Write then read via the RX/TX split path.
data := bytes.Repeat([]byte{0xBB}, 4096)
sendSCSIWriteImmediate(t, env.clientConn, 0, data, 1, 2)
resp, err := ReadPDU(env.clientConn)
if err != nil {
t.Fatal(err)
}
if resp.Opcode() != OpSCSIResp || resp.SCSIStatus() != SCSIStatusGood {
t.Fatalf("write resp: opcode=%s status=%d", OpcodeName(resp.Opcode()), resp.SCSIStatus())
}
sendSCSIRead(t, env.clientConn, 0, 1, 2, 3)
resp, err = ReadPDU(env.clientConn)
if err != nil {
t.Fatal(err)
}
if resp.Opcode() != OpSCSIDataIn {
t.Fatalf("expected DataIn, got %s", OpcodeName(resp.Opcode()))
}
if !bytes.Equal(resp.DataSegment, data) {
t.Error("read data mismatch")
}
}
func testRXTXPipelinedReads(t *testing.T) {
env := setupSession(t)
doLogin(t, env.clientConn)
// Send 4 reads back-to-back.
for i := uint32(0); i < 4; i++ {
sendSCSIRead(t, env.clientConn, i, 1, i+1, i+2)
}
// Receive all 4 responses.
for i := 0; i < 4; i++ {
resp, err := ReadPDU(env.clientConn)
if err != nil {
t.Fatalf("read %d: %v", i, err)
}
if resp.Opcode() != OpSCSIDataIn {
t.Fatalf("read %d: expected DataIn, got %s", i, OpcodeName(resp.Opcode()))
}
}
}
func testRXTXPipelinedWrites(t *testing.T) {
env := setupSession(t)
doLogin(t, env.clientConn)
// Send 8 writes back-to-back with immediate data.
for i := uint32(0); i < 8; i++ {
data := bytes.Repeat([]byte{byte(0xA0 + i)}, 4096)
sendSCSIWriteImmediate(t, env.clientConn, i, data, i+1, i+2)
}
// Receive all 8 responses.
for i := 0; i < 8; i++ {
resp, err := ReadPDU(env.clientConn)
if err != nil {
t.Fatalf("write %d: %v", i, err)
}
if resp.Opcode() != OpSCSIResp {
t.Fatalf("write %d: expected SCSIResp, got %s", i, OpcodeName(resp.Opcode()))
}
if resp.SCSIStatus() != SCSIStatusGood {
t.Fatalf("write %d: status=%d", i, resp.SCSIStatus())
}
}
}
func testRXTXWriteWithR2T(t *testing.T) {
env := setupSession(t)
doLogin(t, env.clientConn)
// Write without immediate data -- should trigger R2T + Data-Out.
cmd := &PDU{}
cmd.SetOpcode(OpSCSICmd)
cmd.SetOpSpecific1(FlagF | FlagW) // no immediate data
cmd.SetInitiatorTaskTag(1)
cmd.SetExpectedDataTransferLength(4096)
cmd.SetCmdSN(0)
var cdb [16]byte
cdb[0] = ScsiWrite10
binary.BigEndian.PutUint32(cdb[2:6], 10)
binary.BigEndian.PutUint16(cdb[7:9], 1)
cmd.SetCDB(cdb)
// No DataSegment = no immediate data
if err := WritePDU(env.clientConn, cmd); err != nil {
t.Fatal(err)
}
// Expect R2T from target.
r2t, err := ReadPDU(env.clientConn)
if err != nil {
t.Fatal(err)
}
if r2t.Opcode() != OpR2T {
t.Fatalf("expected R2T, got %s", OpcodeName(r2t.Opcode()))
}
// Send Data-Out.
dataOut := &PDU{}
dataOut.SetOpcode(OpSCSIDataOut)
dataOut.SetOpSpecific1(FlagF) // Final
dataOut.SetInitiatorTaskTag(1)
dataOut.SetTargetTransferTag(r2t.TargetTransferTag())
dataOut.SetDataSN(0)
dataOut.SetBufferOffset(0)
dataOut.DataSegment = bytes.Repeat([]byte{0xCC}, 4096)
if err := WritePDU(env.clientConn, dataOut); err != nil {
t.Fatal(err)
}
// Expect SCSI Response.
resp, err := ReadPDU(env.clientConn)
if err != nil {
t.Fatal(err)
}
if resp.Opcode() != OpSCSIResp {
t.Fatalf("expected SCSIResp, got %s", OpcodeName(resp.Opcode()))
}
if resp.SCSIStatus() != SCSIStatusGood {
t.Fatalf("status=%d", resp.SCSIStatus())
}
}
func testRXTXMixedOps(t *testing.T) {
env := setupSession(t)
doLogin(t, env.clientConn)
// Interleave: WRITE, READ, NOP, READ, WRITE
cmdSN := uint32(2)
// WRITE
sendSCSIWriteImmediate(t, env.clientConn, 0, bytes.Repeat([]byte{0x11}, 4096), 1, cmdSN)
cmdSN++
resp, _ := ReadPDU(env.clientConn)
if resp.Opcode() != OpSCSIResp {
t.Fatalf("write: got %s", OpcodeName(resp.Opcode()))
}
// READ
sendSCSIRead(t, env.clientConn, 0, 1, 2, cmdSN)
cmdSN++
resp, _ = ReadPDU(env.clientConn)
if resp.Opcode() != OpSCSIDataIn {
t.Fatalf("read: got %s", OpcodeName(resp.Opcode()))
}
// NOP
nop := &PDU{}
nop.SetOpcode(OpNOPOut)
nop.SetOpSpecific1(FlagF)
nop.SetInitiatorTaskTag(3)
nop.SetImmediate(true)
WritePDU(env.clientConn, nop)
resp, _ = ReadPDU(env.clientConn)
if resp.Opcode() != OpNOPIn {
t.Fatalf("nop: got %s", OpcodeName(resp.Opcode()))
}
// TEST_UNIT_READY (CDB[0] = 0x00)
tuCmd := &PDU{}
tuCmd.SetOpcode(OpSCSICmd)
tuCmd.SetOpSpecific1(FlagF)
tuCmd.SetInitiatorTaskTag(4)
tuCmd.SetCmdSN(cmdSN)
var tuCDB [16]byte
tuCmd.SetCDB(tuCDB) // all zeros = TEST_UNIT_READY
WritePDU(env.clientConn, tuCmd)
resp, _ = ReadPDU(env.clientConn)
if resp.Opcode() != OpSCSIResp {
t.Fatalf("tur: got %s", OpcodeName(resp.Opcode()))
}
}
func testRXTXLargeReadDataIn(t *testing.T) {
env := setupSessionWithConfig(t, func(c *TargetConfig) {
c.MaxRecvDataSegmentLength = 4096 // small: force multi-PDU
})
doLogin(t, env.clientConn)
// Read 4 blocks = 16384 bytes. With MaxRecvDataSegmentLength=4096,
// this should produce 4 Data-In PDUs.
sendSCSIRead(t, env.clientConn, 0, 4, 1, 2)
var pdus []*PDU
for {
p, err := ReadPDU(env.clientConn)
if err != nil {
t.Fatal(err)
}
pdus = append(pdus, p)
if p.OpSpecific1()&FlagS != 0 {
break // final PDU with S-bit
}
}
if len(pdus) < 2 {
t.Fatalf("expected >= 2 Data-In PDUs, got %d", len(pdus))
}
// Reconstruct data.
totalData := make([]byte, 0)
for _, p := range pdus {
totalData = append(totalData, p.DataSegment...)
}
if len(totalData) != 4*4096 {
t.Fatalf("total data length: %d, want %d", len(totalData), 4*4096)
}
}
func testRXTXStatSNMonotonic(t *testing.T) {
env := setupSession(t)
doLogin(t, env.clientConn)
var lastStatSN uint32
cmdSN := uint32(2)
for i := 0; i < 10; i++ {
sendSCSIRead(t, env.clientConn, 0, 1, uint32(i+1), cmdSN)
cmdSN++
resp, err := ReadPDU(env.clientConn)
if err != nil {
t.Fatal(err)
}
statSN := resp.StatSN()
if i > 0 && statSN != lastStatSN+1 {
t.Fatalf("StatSN not monotonic: prev=%d, curr=%d", lastStatSN, statSN)
}
lastStatSN = statSN
}
}
func testRXTXStatSNDataInNoIncrement(t *testing.T) {
env := setupSessionWithConfig(t, func(c *TargetConfig) {
c.MaxRecvDataSegmentLength = 4096 // small: force multi-PDU
})
doLogin(t, env.clientConn)
// Read 4 blocks to get multi-PDU Data-In.
sendSCSIRead(t, env.clientConn, 0, 4, 1, 2)
var pdus []*PDU
for {
p, err := ReadPDU(env.clientConn)
if err != nil {
t.Fatal(err)
}
pdus = append(pdus, p)
if p.OpSpecific1()&FlagS != 0 {
break
}
}
if len(pdus) < 2 {
t.Fatalf("expected >= 2 Data-In PDUs, got %d", len(pdus))
}
// Intermediate PDUs should NOT have StatSN incremented.
// Only the final PDU (with S-bit) should have StatSN.
finalPDU := pdus[len(pdus)-1]
if finalPDU.OpSpecific1()&FlagS == 0 {
t.Fatal("last PDU missing S-bit")
}
// StatSN on final PDU should be set.
statSN := finalPDU.StatSN()
if statSN == 0 {
// It's actually valid for StatSN to be any value (depends on login StatSN).
// Just verify it's set on the final PDU.
}
_ = statSN
}
func testRXTXStatSNMixedTypes(t *testing.T) {
env := setupSession(t)
doLogin(t, env.clientConn)
cmdSN := uint32(2)
var statSNs []uint32
// NOP
nop := &PDU{}
nop.SetOpcode(OpNOPOut)
nop.SetOpSpecific1(FlagF)
nop.SetInitiatorTaskTag(1)
nop.SetImmediate(true)
WritePDU(env.clientConn, nop)
resp, _ := ReadPDU(env.clientConn)
statSNs = append(statSNs, resp.StatSN())
// SCSI READ
sendSCSIRead(t, env.clientConn, 0, 1, 2, cmdSN)
cmdSN++
resp, _ = ReadPDU(env.clientConn)
statSNs = append(statSNs, resp.StatSN())
// Logout
logout := &PDU{}
logout.SetOpcode(OpLogoutReq)
logout.SetOpSpecific1(FlagF)
logout.SetInitiatorTaskTag(3)
logout.SetCmdSN(cmdSN)
WritePDU(env.clientConn, logout)
resp, _ = ReadPDU(env.clientConn)
statSNs = append(statSNs, resp.StatSN())
// All StatSNs should be strictly increasing.
for i := 1; i < len(statSNs); i++ {
if statSNs[i] != statSNs[i-1]+1 {
t.Errorf("StatSN not monotonic at %d: %v", i, statSNs)
}
}
}
func testRXTXShutdownClean(t *testing.T) {
env := setupSession(t)
doLogin(t, env.clientConn)
// Send logout.
logout := &PDU{}
logout.SetOpcode(OpLogoutReq)
logout.SetOpSpecific1(FlagF)
logout.SetInitiatorTaskTag(1)
logout.SetCmdSN(0)
WritePDU(env.clientConn, logout)
// Read logout response.
resp, err := ReadPDU(env.clientConn)
if err != nil {
t.Fatal(err)
}
if resp.Opcode() != OpLogoutResp {
t.Fatalf("expected LogoutResp, got %s", OpcodeName(resp.Opcode()))
}
// HandleConnection should exit cleanly.
select {
case err := <-env.done:
if err != nil {
t.Fatalf("error after logout: %v", err)
}
case <-time.After(2 * time.Second):
t.Fatal("session did not exit after logout")
}
}
func testRXTXConnDropReader(t *testing.T) {
env := setupSession(t)
doLogin(t, env.clientConn)
// Close client conn -- reader should detect EOF.
env.clientConn.Close()
select {
case err := <-env.done:
if err != nil {
t.Fatalf("error on conn drop: %v", err)
}
case <-time.After(2 * time.Second):
t.Fatal("session did not exit after conn drop")
}
}
func testRXTXConnDropWriter(t *testing.T) {
client, server := net.Pipe()
dev := newMockDevice(1024 * 4096)
config := DefaultTargetConfig()
config.TargetName = testTargetName
resolver := newTestResolverWithDevice(dev)
logger := log.New(io.Discard, "", 0)
sess := NewSession(server, config, resolver, resolver, logger)
done := make(chan error, 1)
go func() {
done <- sess.HandleConnection()
}()
doLogin(t, client)
// Close the server side (writer side for txLoop).
server.Close()
// Session should exit.
select {
case <-done:
// Good -- session exited.
case <-time.After(2 * time.Second):
t.Fatal("session did not exit after server-side close")
}
client.Close()
}
// testRXTXR2TStatSNFresh verifies that R2T PDUs carry a fresh StatSN
// (assigned by txLoop) rather than a stale value baked in at build time.
func testRXTXR2TStatSNFresh(t *testing.T) {
env := setupSession(t)
doLogin(t, env.clientConn)
// Send a NOP-Out (immediate) -- its NOP-In response will consume one StatSN.
nop := &PDU{}
nop.SetOpcode(OpNOPOut)
nop.SetOpSpecific1(FlagF)
nop.SetInitiatorTaskTag(0x1000)
nop.SetImmediate(true)
if err := WritePDU(env.clientConn, nop); err != nil {
t.Fatal(err)
}
// Read NOP-In response.
nopResp, err := ReadPDU(env.clientConn)
if err != nil {
t.Fatal(err)
}
if nopResp.Opcode() != OpNOPIn {
t.Fatalf("expected NOP-In, got %s", OpcodeName(nopResp.Opcode()))
}
nopStatSN := nopResp.StatSN()
// Now send a WRITE command without immediate data to trigger R2T.
cmd := &PDU{}
cmd.SetOpcode(OpSCSICmd)
cmd.SetOpSpecific1(FlagF | FlagW)
cmd.SetInitiatorTaskTag(1)
cmd.SetExpectedDataTransferLength(4096)
cmd.SetCmdSN(0)
var cdb [16]byte
cdb[0] = ScsiWrite10
binary.BigEndian.PutUint32(cdb[2:6], 0)
binary.BigEndian.PutUint16(cdb[7:9], 1)
cmd.SetCDB(cdb)
if err := WritePDU(env.clientConn, cmd); err != nil {
t.Fatal(err)
}
// Read R2T -- its StatSN should be nopStatSN+1 (fresh, not stale).
r2t, err := ReadPDU(env.clientConn)
if err != nil {
t.Fatal(err)
}
if r2t.Opcode() != OpR2T {
t.Fatalf("expected R2T, got %s", OpcodeName(r2t.Opcode()))
}
// R2T uses statSNCopy: same as current StatSN (which is nopStatSN+1) without increment.
expectedStatSN := nopStatSN + 1
if r2t.StatSN() != expectedStatSN {
t.Fatalf("R2T StatSN: got %d, want %d (NOP-In was %d)", r2t.StatSN(), expectedStatSN, nopStatSN)
}
// Complete the write by sending Data-Out.
dataOut := &PDU{}
dataOut.SetOpcode(OpSCSIDataOut)
dataOut.SetOpSpecific1(FlagF)
dataOut.SetInitiatorTaskTag(1)
dataOut.SetTargetTransferTag(r2t.TargetTransferTag())
dataOut.SetDataSN(0)
dataOut.SetBufferOffset(0)
dataOut.DataSegment = bytes.Repeat([]byte{0xDD}, 4096)
if err := WritePDU(env.clientConn, dataOut); err != nil {
t.Fatal(err)
}
// Read SCSI response -- its StatSN should be nopStatSN+1 (R2T didn't increment).
resp, err := ReadPDU(env.clientConn)
if err != nil {
t.Fatal(err)
}
if resp.Opcode() != OpSCSIResp {
t.Fatalf("expected SCSIResp, got %s", OpcodeName(resp.Opcode()))
}
if resp.StatSN() != expectedStatSN {
t.Fatalf("SCSI Resp StatSN: got %d, want %d", resp.StatSN(), expectedStatSN)
}
}
// testRXTXTxErrorExitsClean verifies that when txLoop encounters a write error,
// HandleConnection exits cleanly without hanging.
func testRXTXTxErrorExitsClean(t *testing.T) {
client, server := net.Pipe()
dev := newMockDevice(1024 * 4096)
config := DefaultTargetConfig()
config.TargetName = testTargetName
resolver := newTestResolverWithDevice(dev)
logger := log.New(io.Discard, "", 0)
sess := NewSession(server, config, resolver, resolver, logger)
done := make(chan error, 1)
go func() {
done <- sess.HandleConnection()
}()
doLogin(t, client)
// Close the server side -- this makes txLoop's WritePDU fail.
server.Close()
// Send something from client side so rxLoop dispatches and enqueues a response.
// The enqueue should not block forever thanks to txDone select.
nop := &PDU{}
nop.SetOpcode(OpNOPOut)
nop.SetOpSpecific1(FlagF)
nop.SetInitiatorTaskTag(1)
nop.SetImmediate(true)
// Write may fail since server is closed; that's fine.
WritePDU(client, nop)
// HandleConnection should exit cleanly without hanging.
select {
case <-done:
// Good.
case <-time.After(3 * time.Second):
t.Fatal("HandleConnection did not exit after tx write error")
}
// Verify txDone is closed (no goroutine leak).
select {
case <-sess.txDone:
// Good -- txLoop exited.
default:
t.Fatal("txDone not closed -- txLoop may be leaked")
}
client.Close()
}
// testRXTXLoginPhaseReject verifies that handlers reject PDUs sent before
// login completes and that login can still succeed afterward.
func testRXTXLoginPhaseReject(t *testing.T) {
client, server := net.Pipe()
dev := newMockDevice(1024 * 4096)
config := DefaultTargetConfig()
config.TargetName = testTargetName
resolver := newTestResolverWithDevice(dev)
logger := log.New(io.Discard, "", 0)
sess := NewSession(server, config, resolver, resolver, logger)
done := make(chan error, 1)
go func() {
done <- sess.HandleConnection()
}()
defer func() {
sess.Close()
client.Close()
}()
// Send a TextReq before login -- should get a Reject (inline, not buffered).
textParams := NewParams()
textParams.Set("SendTargets", "All")
textReq := makeTextReq(textParams)
textReq.SetCmdSN(1)
if err := WritePDU(client, textReq); err != nil {
t.Fatal(err)
}
resp, err := ReadPDU(client)
if err != nil {
t.Fatal(err)
}
if resp.Opcode() != OpReject {
t.Fatalf("expected Reject, got %s", OpcodeName(resp.Opcode()))
}
if resp.BHS[2] != 0x0b {
t.Fatalf("reject reason: got 0x%02x, want 0x0b", resp.BHS[2])
}
// Login should still work after the reject.
doLogin(t, client)
// Verify session is functional by sending a NOP.
nop := &PDU{}
nop.SetOpcode(OpNOPOut)
nop.SetOpSpecific1(FlagF)
nop.SetInitiatorTaskTag(0x2222)
nop.SetImmediate(true)
if err := WritePDU(client, nop); err != nil {
t.Fatal(err)
}
nopResp, err := ReadPDU(client)
if err != nil {
t.Fatal(err)
}
if nopResp.Opcode() != OpNOPIn {
t.Fatalf("expected NOP-In, got %s", OpcodeName(nopResp.Opcode()))
}
}
// testRXTXPendingQueueOverflow verifies that flooding non-Data-Out PDUs
// during Data-Out collection causes the session to close (not OOM).
func testRXTXPendingQueueOverflow(t *testing.T) {
env := setupSessionWithConfig(t, func(cfg *TargetConfig) {
cfg.MaxRecvDataSegmentLength = 4096
cfg.FirstBurstLength = 0 // force R2T
cfg.DataOutTimeout = 5 * time.Second
})
doLogin(t, env.clientConn)
// Start WRITE requiring R2T.
cmd := &PDU{}
cmd.SetOpcode(OpSCSICmd)
cmd.SetOpSpecific1(FlagF | FlagW)
cmd.SetInitiatorTaskTag(0xAAAA)
cmd.SetExpectedDataTransferLength(4096)
cmd.SetCmdSN(0)
var cdb [16]byte
cdb[0] = ScsiWrite10
binary.BigEndian.PutUint32(cdb[2:6], 0)
binary.BigEndian.PutUint16(cdb[7:9], 1)
cmd.SetCDB(cdb)
if err := WritePDU(env.clientConn, cmd); err != nil {
t.Fatal(err)
}
// Read R2T.
r2t, err := ReadPDU(env.clientConn)
if err != nil {
t.Fatal(err)
}
if r2t.Opcode() != OpR2T {
t.Fatalf("expected R2T, got %s", OpcodeName(r2t.Opcode()))
}
// Flood 100 NOP-Out PDUs during Data-Out collection.
for i := 0; i < 100; i++ {
nop := &PDU{}
nop.SetOpcode(OpNOPOut)
nop.SetOpSpecific1(FlagF)
nop.SetInitiatorTaskTag(uint32(0xB000 + i))
nop.SetImmediate(true)
if err := WritePDU(env.clientConn, nop); err != nil {
break // session closed
}
}
// Session should exit with an error (pending queue overflow).
select {
case <-env.done:
// Good -- session exited.
case <-time.After(3 * time.Second):
t.Fatal("session did not exit after pending overflow")
}
}
// testRXTXDataOutTimeout verifies that collectDataOut times out
// when the initiator never sends Data-Out after R2T.
func testRXTXDataOutTimeout(t *testing.T) {
env := setupSessionWithConfig(t, func(cfg *TargetConfig) {
cfg.MaxRecvDataSegmentLength = 4096
cfg.FirstBurstLength = 0 // force R2T
cfg.DataOutTimeout = 500 * time.Millisecond
})
doLogin(t, env.clientConn)
// Send WRITE command requiring R2T (no immediate data).
cmd := &PDU{}
cmd.SetOpcode(OpSCSICmd)
cmd.SetOpSpecific1(FlagF | FlagW)
cmd.SetInitiatorTaskTag(0xBEEF)
cmd.SetExpectedDataTransferLength(4096)
cmd.SetCmdSN(0)
var cdb [16]byte
cdb[0] = ScsiWrite10
binary.BigEndian.PutUint32(cdb[2:6], 0)
binary.BigEndian.PutUint16(cdb[7:9], 1)
cmd.SetCDB(cdb)
if err := WritePDU(env.clientConn, cmd); err != nil {
t.Fatal(err)
}
// Read R2T.
r2t, err := ReadPDU(env.clientConn)
if err != nil {
t.Fatal(err)
}
if r2t.Opcode() != OpR2T {
t.Fatalf("expected R2T, got %s", OpcodeName(r2t.Opcode()))
}
// Do NOT send Data-Out. Session should time out and exit.
select {
case err := <-env.done:
t.Logf("session exited: %v", err)
case <-time.After(3 * time.Second):
t.Fatal("session did not time out -- DataOutTimeout not working")
}
}