Skip to content

Net::FTP has no #initialize: construction is wired to .new and to nothing else (-38 examples, the largest item left in net-ftp) #929

Open

Description

@tannevaled

Net::FTP has no #initialize: construction is wired to .new and to nothing else

Measured against MRI 4.0.6 (net-ftp 0.3.9) over all 56 library/net-ftp files,
same corpus, 30 s per file:

engine passing
MRI 4.0.6 459
rbgo 331
−128

initialize_spec.rb alone is −38 of that, and it is the largest single item
left in the module: MRI passes 41, rbgo passes 3.

The cause

Net::FTP#initialize is not defined. Construction lives entirely in the
class method Net::FTP.new, so the class inherits Object#initialize, which
takes no arguments:

RBGO_RESULT pass=3 fail=2 error=36 skip=0
RBGO_DETAIL error ArgumentError  Net::FTP#initialize when passed host | tries to connect to the passed host
                                 wrong number of arguments (given 1, expected 0)

ruby/spec drives initialize directly, which is normal for this module —
it is how the suite tests construction without connecting:

@ftp.send(:initialize, nil, options)
@ftp.passive.should == false

and it asserts the method is there and private:

it "is private" do
  Net::FTP.private_instance_methods(false).should.include?(:initialize)
end

The shape

This is the defect this repository keeps producing: a mechanism wired at only
some of its doors.
Construction works perfectly through .new and does not
exist through #initialize, which is the door MRI documents as a private
instance method and which ruby/spec uses throughout.

It is the tenth instance recorded this week, after ioBlock in Net::HTTP but
not Net::FTP, read_timeout read from the options Hash but not
open_timeout, and ftpInit returning before the options when the host was
nil.

Shape of the fix

Define Net::FTP#initialize(host = nil, options = {}) as a private instance
method
carrying what ftpInit does today, and make Net::FTP.new allocate
and call it. Then .new, .open and send(:initialize, …) all go through one
implementation instead of one door being a special case.

Worth checking while there: MRI calls options.to_hash on the argument, so an
object that converts counts as an options Hash; rbgo currently requires a
literal Hash.

Not a hang

For the record, since this module is cited in #916: with a 30 s per-file limit,
no file is killed by the clock under either engine — specdelta prints no
KILLED on timeout row in this sweep. The remaining −128 is ordinary
unimplemented behaviour, not the hang symptom.

Prior art: priorart "Net::FTP#initialize private instance method send" at 2026-11-13 14:40 CET reported prior art; checked: priorart --repo go-embedded-ruby/ruby 'Net::FTP#initialize private instance method send' returned 508 broad keyword matches; the nearest are #916 (the net-ftp hang issue, which this explicitly is NOT), #928 (my own open PR, which adds default_passive and fixes ftpInit dropping options on a nil host but does not define #initialize), and #621 (an mspec shim gap). None covers Net::FTP#initialize being undefined.

Activity

  1. tannevaled commented on Nov 13, 2026

    @tannevaled
    ContributorAuthor

    Before implementing this: Net::FTP.allocate does not produce a Net::FTP

    Defining #initialize is necessary and not sufficient, because of how the
    spec reaches it:

    before :each do
      @ftp = Net::FTP.allocate
      @ftp.stub!(:connect)
    end

    Measured on main:

    $ rbgo -e 'require "net/ftp"; a = Net::FTP.allocate; p a.class; p a.passive'
    Net::FTP
    ArgumentError: passive: interface conversion: object.Value is *vm.RObject, not *vm.ftpObj
    

    allocate hands back a generic *RObject that merely reports the class
    Net::FTP. Every accessor then fails its type assertion, so an #initialize
    defined on top of it would be handed an object it cannot populate. The 38
    examples in initialize_spec.rb all start from that allocate.

    So the work is larger than "define a private #initialize":

    1. a class whose instances are backed by a Go struct needs an allocator the
      class itself provides, and Class#allocate has to consult it instead of
      building a plain RObject;
    2. then #initialize can be a private instance method carrying what ftpInit
      does today, with .new becoming allocate-then-initialize;
    3. and only then does send(:initialize, nil, options) reach a real ftpObj.

    Step 1 is not specific to Net::FTP — every native-backed class has the same
    hole, so it wants doing once in the object model rather than per binding. That
    is a different and wider change than this issue was filed for, and worth saying
    before someone starts at step 2.

    ⛔ A separate, smaller defect found on the way

    The failure above is a Go interface-conversion message reaching Ruby:

    ArgumentError: passive: interface conversion: object.Value is *vm.RObject, not *vm.ftpObj
    

    Internal type names have no business in a user-visible exception, and the class
    is wrong too — MRI would raise NoMethodError or TypeError here, not
    ArgumentError. Whatever happens to the rest of this issue, that message is
    worth not shipping: it tells an embedder about *vm.ftpObj, and it will be
    produced by any native-backed class reached through allocate.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions