Skip to content

Commit 16ca3da

Browse files
pgodwinclaude
andcommitted
feat(afp): implement DSI (AFP-over-TCP) for client and server
Closes the one gap the refactor TODO audit found: AFP.transports=["tcp"]/tcp_addr were accepted and round-tripped in config but nothing ever opened the listener. - core/protocol/dsi: the shared 16-byte DSI header codec (pure, core-ring), recovered from the pre-refactor service/dsi implementation and corrected — the AFP result code belongs in the header's ErrorCode field, not prepended to the reply payload as the old code did (which would have corrupted every real reply). See spec/21-dsi.md. - adapter/dsi: server-side TCP transport driving the existing afp.CommandHandler/CommandCircuit seam, the same way adapter/smbtcp drives SMB. - client/dsi: client-side session with an async read loop that demuxes the server's unsolicited Tickle/Attention pushes from Command replies by RequestID, since a TCP stream doesn't get ASP's packet-multiplexing for free. - client/afp: FS.sess is now a Session interface (client/afp/session.go) instead of a concrete *aspclient.Session, so the existing command plumbing and reconnect-on-drop logic work over either ASP or DSI; -ifacetype tcp now actually dials instead of returning "not implemented". - Wired into compose (wireDSI, mirroring wireSMBTCP) and core/service/afp gained Binds/SetTCPListenAddr/TCPListenAddr matching *smb.Service's shape. - New test/e2e case afp/dsi runs the full file-op battery (forks, type/creator, rename, delete) over the real client<->server DSI path, plus unit tests in all three new packages. - docs/config.md, docs/protocols.md, server.toml.example, .refactor/TODO.md and README.md updated to drop the "not yet implemented" language now that it's real. SMB-over-TCP (adapter/smbtcp + client/smb's DialTCP) was already fully implemented and tested on both ends before this change; no code needed there. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent a2b678b commit 16ca3da

29 files changed

Lines changed: 1730 additions & 105 deletions

.refactor/README.md

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -36,13 +36,13 @@ agents or people working in parallel.
3636
## Status
3737

3838
- Phase 1: ✅ complete (harness, interfaces, placeholders, all groups A–E)
39-
- Phase 2: ✅ complete except **M7a** (AFP-over-TCP/DSI transport `adapter/dsi` was never built;
40-
`AFP.TCPAddr`/`transports = ["tcp"]` round-trip in config but stay inert). The cutover (M10)
41-
shipped 2026-06-18: `internal/app` and the legacy `port`/`protocol`/`router`/`service`/`config`/
42-
`capture`/`pkg` tree are deleted; `cmd/classicstack` runs on the new ring. Everything built since
43-
cutover (the file client, the web admin SPA, the tray app, TashTalk/LToUDP LocalTalk, direct-hosted
44-
SMB-over-IPX, the Windows installer, …) is feature work on top of the new architecture, not part of
45-
the migration itself. See [TODO.md](TODO.md) for the per-step record — re-verified against the
46-
running code on 2026-08-23.
39+
- Phase 2: ✅ complete, including **M7a** (AFP-over-TCP/DSI — `adapter/dsi` + `client/dsi`, landed
40+
2026-08-23; see spec/21-dsi.md). The cutover (M10) shipped 2026-06-18: `internal/app` and the
41+
legacy `port`/`protocol`/`router`/`service`/`config`/`capture`/`pkg` tree are deleted; `cmd/
42+
classicstack` runs on the new ring. Everything built since cutover (the file client, the web admin
43+
SPA, the tray app, TashTalk/LToUDP LocalTalk, direct-hosted SMB-over-IPX, AFP-over-TCP/DSI, the
44+
Windows installer, …) is feature work on top of the new architecture, not part of the migration
45+
itself. See [TODO.md](TODO.md) for the per-step record — re-verified against the running code on
46+
2026-08-23.
4747

4848
See [TODO.md](TODO.md) for per-step status.

.refactor/TODO.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,7 @@ Fill **Owner** when claimed. **Deps** must be ✅ before starting (✋ = can par
9999
| M6 | Storage seam: unified FS, metastore, fork engines (SFM/Netatalk interop), name engines, **filename codecs** (MacRoman/reserved, from path_codec.go) | Phase 1 (B8/B9) | claude ||
100100
| M6a | `core/fs` `ShareSpec.Path`+`Extra` param bag + per-fs_type `Param` schema (`RegisterFSWithParams`/`ParamsFor`, `BuildShare` required-param validation); real `local_fs` factory from `spec.Path`; metadata-carrying `ForkFS.Rename`/`Remove` (§9/§9d) | M6 | claude ||
101101
| M7 | File services AFP/SMB/NetBIOS over fs/metastore + Attachable transports (pure command cores; in-core ASP/NetBIOS transports) | M6,M2 | claude ||
102-
| M7a | `adapter/dsi` (AFP-over-TCP `:548`): re-home `service/dsi` onto AFP command core's CommandHandler; `//go:build dsi`; net only here (§1/§3-bis). **Prereq DONE:** the transport-agnostic AFP command-core seam now exists (`core/service/afp/conn.go``Conn`/`Command`/`Close` + `CommandHandler`/`CommandCircuit`, the AFP analogue of SMB's conn.go); ASP now drives it, so the adapter binds to the seam not the ASP spine. **Re-verified 2026-08-23: still the one open item.** No `adapter/dsi` package exists; `AFP.TCPAddr`/`TransportTCP` are modeled and round-trip in config but are explicitly documented as inert (`server.toml.example`: "Inert until the DSI transport lands"). `reg_afp.go` only ever binds the DDP/ASP transport to the router. This is now the **only outstanding Phase 2 gap**. | M7 | ||
102+
| M7a | `adapter/dsi` (AFP-over-TCP `:548`): re-home `service/dsi` onto AFP command core's CommandHandler; net only here (§1/§3-bis). **Landed 2026-08-23.** `core/protocol/dsi` (pure header codec, ported from the pre-refactor `service/dsi` with the correctness fix below), `adapter/dsi` (server transport driving `afp.CommandHandler`/`CommandCircuit`), `client/dsi` (client session — async read loop demuxing Attention/Tickle from replies by RequestID), and a client-side `client/afp.Session` interface so `client/afp`'s command plumbing (including reconnect-on-drop) works over either ASP or DSI. `*afp.Service` gained `Binds`/`SetTCPListenAddr`/`TCPListenAddr` mirroring SMB's; `wireDSI` in `compose/runtime/transports.go` cross-wires them exactly like `wireSMBTCP`. **Correctness fix over the recovered pre-refactor code:** the AFP result code goes in the DSI header's ErrorCode field, not prepended to the payload — the old implementation did the latter, which would have corrupted every real reply; see `spec/21-dsi.md`'s errata. Proven end-to-end by `test/e2e`'s new `afp/dsi` case (real client `dsi.Session` + real `adapter/dsi`-shaped framing + real `afp.Service`, full file-op battery incl. forks) alongside unit tests in `core/protocol/dsi`, `adapter/dsi`, and `client/dsi`. **No local capture exists yet to verify against a real classic-Mac/third-party DSI client — see spec/21-dsi.md's Sources note.** | M7 | claude | ✅ |
103103
| M7b | `adapter/smbtcp` (SMB **direct-TCP `:445`** framing, 4-byte length prefix) onto SMB command core; `//go:build smbtcp`; net only in adapter (§3-bis) | M7 | claude ||
104104
| M7b2 | `adapter/netbios-tcp` (**NBT**, RFC 1001/1002: name udp137 / datagram udp138 / session tcp139) — TCP sibling of NBF/NBIPX; session half → NetBIOS `SessionConsumer`, datagram half → `DatagramConsumer`; adds NO SMB/browser code, only the wire transport; `//go:build nbt`; net only in adapter (§3-ter). Most vintage TCP clients use `:139`, not `:445`. **Landed differently than planned:** no separate `adapter/netbios-tcp` package — `adapter/smbtcp` grew into the shared substrate for both `:445` (direct-hosted SMB) and `:139` (NBT), since both are a 4-byte-length-prefixed session-message stream (the RFC 1001 SESSION REQUEST/RESPONSE handshake `:139` adds is accepted-and-ignored). `netbios.Section.NBTAddr` + `compose/runtime/transports.go` wire the NBT binding onto it. The name-service (udp137) and datagram (udp138) halves ride the existing NetBEUI/IPX `DatagramConsumer` seam, not a new UDP listener. | M7 | claude ||
105105
| M7e | **SMB direct-hosted over IPX** (socket `0x0550`, "NWLink direct host") — a CORE transport (no `net`, no NetBIOS layer): connection-id framing on the IPX mini-router driving the SAME SMB `SessionConsumer` seam as NBF/NBIPX. Re-home from legacy `service/smb/over_ipx_direct`. Proves SMB runs over IPX both with NetBIOS (NBIPX, 0x0455) and without (direct, 0x0550). | M7 | claude ||

adapter/dsi/doc.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
// Package dsi is the DSI-over-TCP session transport for AFP: it accepts TCP
2+
// connections (conventionally :548), frames each as a stream of core/protocol/dsi
3+
// headers + data, and drives the transport-agnostic afp.CommandHandler/CommandCircuit
4+
// seam (core/service/afp/conn.go) — the AFP analogue of adapter/smbtcp driving
5+
// smb.SessionConsumer. It is the "modern" AFP transport (TCP → DSI → AFP), the
6+
// counterpart to the "classic" ASP-over-DDP transport that lives in core/service/afp
7+
// itself.
8+
//
9+
// Ring: ADAPTER. It uses net (forbidden in core), so the listener lives here, not in
10+
// core/service/afp — mirroring how pcap/serial device I/O and the SMB-TCP listener
11+
// live in adapters. It reaches AFP only through the small CommandHandler/CommandCircuit
12+
// interfaces, so it never imports the AFP command internals.
13+
package dsi

adapter/dsi/dsi.go

Lines changed: 250 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,250 @@
1+
package dsi
2+
3+
import (
4+
"context"
5+
"io"
6+
"net"
7+
"sync"
8+
9+
"github.com/ObsoleteMadness/ClassicStack/core/component"
10+
dsiproto "github.com/ObsoleteMadness/ClassicStack/core/protocol/dsi"
11+
"github.com/ObsoleteMadness/ClassicStack/core/service/afp"
12+
13+
"github.com/ObsoleteMadness/ClassicStack/core/log"
14+
)
15+
16+
// Name is the component name for the AFP-over-TCP (DSI) transport. It is its own
17+
// supervised component (a listener with a lifecycle), distinct from the AFP command
18+
// service.
19+
const Name = "DSI"
20+
21+
// maxMessage caps a single DSI data block at 16 MiB — well above any real AFP
22+
// command/write payload — so a malformed DataLen header cannot drive an unbounded
23+
// allocation.
24+
const maxMessage = 16 << 20
25+
26+
// Transport is a TCP listener that drives the AFP command-core seam over DSI framing.
27+
// One accept loop spawns a goroutine per connection; each connection opens one AFP
28+
// circuit (on OpenSession) and serves DSI requests until the peer closes or sends
29+
// CloseSession.
30+
type Transport struct {
31+
addr string
32+
handler afp.CommandHandler
33+
logger log.Logger
34+
35+
mu sync.Mutex
36+
listener net.Listener
37+
conns map[net.Conn]struct{}
38+
running bool
39+
}
40+
41+
// New builds a DSI transport. addr/handler may be empty/nil at construction (the
42+
// registry builds it inert); the compose transport cross-wire installs the AFP
43+
// command handler and the listen address once the AFP service and its tcp_addr are
44+
// resolved (mirrors adapter/smbtcp.New).
45+
func New(addr string, handler afp.CommandHandler, logger log.Logger) *Transport {
46+
return &Transport{addr: addr, handler: handler, logger: logger, conns: make(map[net.Conn]struct{})}
47+
}
48+
49+
// SetHandler installs the AFP command handler after construction. Must be called
50+
// before Start; a nil handler leaves Start a no-op.
51+
func (t *Transport) SetHandler(h afp.CommandHandler) {
52+
t.mu.Lock()
53+
t.handler = h
54+
t.mu.Unlock()
55+
}
56+
57+
// SetAddr sets/overrides the listen address before Start (compose supplies it from
58+
// the AFP server section's tcp_addr). An empty address keeps Start a no-op.
59+
func (t *Transport) SetAddr(addr string) {
60+
t.mu.Lock()
61+
t.addr = addr
62+
t.mu.Unlock()
63+
}
64+
65+
// Name returns the component name.
66+
func (t *Transport) Name() string { return Name }
67+
68+
// Binding reports the listen address (component.Bindable), so the dashboard shows it.
69+
func (t *Transport) Binding() string { return t.addr }
70+
71+
// Dependencies declares the DSI listener's start-order edge: the AFP service must be
72+
// running first, since the listener drives its command-core seam (and must stop
73+
// before it). Drops in a build without the AFP service.
74+
func (t *Transport) Dependencies() []string { return []string{afp.Name} }
75+
76+
// Start opens the listener and begins accepting. Idempotent (§3). A nil handler or an
77+
// empty address makes Start a no-op so a build that wires the transport but does not
78+
// configure tcp_addr stays inert rather than erroring.
79+
//
80+
// A bind failure is NON-FATAL, matching the other transports' graceful-degradation
81+
// posture: Start logs a warning and returns nil rather than aborting the whole
82+
// stack's bring-up.
83+
func (t *Transport) Start(_ context.Context) error {
84+
t.mu.Lock()
85+
defer t.mu.Unlock()
86+
if t.running || t.handler == nil || t.addr == "" {
87+
return nil
88+
}
89+
l, err := net.Listen("tcp", t.addr)
90+
if err != nil {
91+
if t.logger != nil {
92+
t.logger.Log(log.Warn, "AFP-over-TCP (DSI) bind failed; transport inert",
93+
log.Str("addr", t.addr), log.Str("error", err.Error()))
94+
}
95+
t.running = true // lifecycle-consistent: "running" but unbound
96+
return nil
97+
}
98+
t.listener = l
99+
t.running = true
100+
go t.acceptLoop(l)
101+
if t.logger != nil {
102+
t.logger.Log(log.Info, "AFP-over-TCP (DSI) listening", log.Str("addr", l.Addr().String()))
103+
}
104+
return nil
105+
}
106+
107+
// Stop closes the listener and every live connection. Safe after a partial Start (§3).
108+
func (t *Transport) Stop(_ context.Context) error {
109+
t.mu.Lock()
110+
if !t.running {
111+
t.mu.Unlock()
112+
return nil
113+
}
114+
t.running = false
115+
l := t.listener
116+
t.listener = nil
117+
conns := make([]net.Conn, 0, len(t.conns))
118+
for c := range t.conns {
119+
conns = append(conns, c)
120+
}
121+
t.mu.Unlock()
122+
123+
if l != nil {
124+
_ = l.Close()
125+
}
126+
for _, c := range conns {
127+
_ = c.Close()
128+
}
129+
return nil
130+
}
131+
132+
func (t *Transport) acceptLoop(l net.Listener) {
133+
for {
134+
conn, err := l.Accept()
135+
if err != nil {
136+
return // listener closed (Stop) or a fatal accept error
137+
}
138+
t.mu.Lock()
139+
if !t.running {
140+
t.mu.Unlock()
141+
_ = conn.Close()
142+
return
143+
}
144+
t.conns[conn] = struct{}{}
145+
t.mu.Unlock()
146+
go t.serve(conn)
147+
}
148+
}
149+
150+
// serve runs one connection: answer sessionless GetStatus directly, open an AFP
151+
// circuit on OpenSession, dispatch Command/Write through it, and close the circuit on
152+
// CloseSession or when the peer disconnects.
153+
func (t *Transport) serve(conn net.Conn) {
154+
var circuit afp.CommandCircuit
155+
defer func() {
156+
if circuit != nil {
157+
circuit.Close()
158+
}
159+
_ = conn.Close()
160+
t.mu.Lock()
161+
delete(t.conns, conn)
162+
t.mu.Unlock()
163+
}()
164+
165+
handler := t.handlerRef()
166+
hdrBuf := make([]byte, dsiproto.HeaderSize)
167+
for {
168+
if _, err := io.ReadFull(conn, hdrBuf); err != nil {
169+
return
170+
}
171+
var h dsiproto.Header
172+
if !h.Unmarshal(hdrBuf) {
173+
return
174+
}
175+
if h.DataLen > maxMessage {
176+
return
177+
}
178+
payload := make([]byte, h.DataLen)
179+
if h.DataLen > 0 {
180+
if _, err := io.ReadFull(conn, payload); err != nil {
181+
return
182+
}
183+
}
184+
185+
switch h.Command {
186+
case dsiproto.GetStatus:
187+
t.reply(conn, h.RequestID, dsiproto.GetStatus, 0, handler.GetServerInfo())
188+
case dsiproto.OpenSession:
189+
if circuit != nil {
190+
circuit.Close()
191+
}
192+
circuit = handler.NewConn()
193+
t.reply(conn, h.RequestID, dsiproto.OpenSession, 0, nil)
194+
case dsiproto.Command, dsiproto.Write:
195+
if circuit == nil {
196+
// A Command/Write before OpenSession is a protocol violation; there is
197+
// no AFP result code for "no session" (that is a DSI-level concern), so
198+
// the connection is simply dropped, matching how the ATP spine answers
199+
// an unknown ASP session id with a hard error rather than serving.
200+
return
201+
}
202+
reply, result := circuit.Command(payload)
203+
t.reply(conn, h.RequestID, h.Command, uint32(result), reply)
204+
case dsiproto.Tickle:
205+
// No reply required (mirrors ASP's SPTickle) — Tickle exists only to reset
206+
// the peer's idle timer, whichever direction it travels.
207+
case dsiproto.CloseSession:
208+
if circuit != nil {
209+
circuit.Close()
210+
circuit = nil
211+
}
212+
t.reply(conn, h.RequestID, dsiproto.CloseSession, 0, nil)
213+
return
214+
default:
215+
// Unknown command: ignore and keep the connection open, matching the old
216+
// server's tolerance of unrecognised DSI commands.
217+
}
218+
}
219+
}
220+
221+
// reply writes one DSI reply frame. The AFP/DSI result code goes in the header's
222+
// ErrorOffset field (its reply-side "ErrorCode" role) — NOT prepended to the payload —
223+
// per the DSI header contract documented in core/protocol/dsi; see spec/21-dsi.md.
224+
func (t *Transport) reply(conn net.Conn, reqID uint16, cmd uint8, errCode uint32, data []byte) {
225+
h := dsiproto.Header{
226+
Flags: dsiproto.Reply,
227+
Command: cmd,
228+
RequestID: reqID,
229+
ErrorOffset: errCode,
230+
DataLen: uint32(len(data)),
231+
}
232+
if _, err := conn.Write(h.Marshal()); err != nil {
233+
return
234+
}
235+
if len(data) > 0 {
236+
_, _ = conn.Write(data)
237+
}
238+
}
239+
240+
func (t *Transport) handlerRef() afp.CommandHandler {
241+
t.mu.Lock()
242+
defer t.mu.Unlock()
243+
return t.handler
244+
}
245+
246+
var (
247+
_ component.Component = (*Transport)(nil)
248+
_ component.Bindable = (*Transport)(nil)
249+
_ component.DependsOn = (*Transport)(nil)
250+
)

0 commit comments

Comments
 (0)